Compare commits
118 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb34973047 | |||
| 108a1890be | |||
| 521c0ffd78 | |||
| 33a37d4105 | |||
| 6ccc0cbed2 | |||
|
|
6e6ffce271 | ||
|
|
23386bb998 | ||
|
|
28b1568486 | ||
|
|
5127971a37 | ||
|
|
0562af8d66 | ||
|
|
e3d3728bbd | ||
|
|
8cc9d075fd | ||
|
|
ede6d967c1 | ||
| c30f6ab318 | |||
| dd2b96b5fd | |||
| c22658db4c | |||
| 7b3893ddc6 | |||
| 9e28fa4981 | |||
| 7549c8fe04 | |||
| 75f699b714 | |||
| 3e821c9092 | |||
| 7602f158a3 | |||
| 052335e842 | |||
| 28fb7404d8 | |||
| 6ab235e1d8 | |||
| fb90c94e37 | |||
| 7a05fc3ac1 | |||
| bf38dac5d7 | |||
| 1e9581528e | |||
| 279872aa3b | |||
| ff6c4b6a4d | |||
| 7fb01ab8d1 | |||
| bdb16d069e | |||
| beb8536e8a | |||
| 63ba69729e | |||
| 01a6c8bab8 | |||
| f70b33b864 | |||
| 0df420e551 | |||
| 488de490f9 | |||
| f15fffa799 | |||
| 1499b5803d | |||
| 39cc70be95 | |||
| f9619e74d9 | |||
| e691336b5e | |||
| 4cee160c12 | |||
| a7ae8ff912 | |||
| d23a1d5b6d | |||
| b417900ddd | |||
| 95f4d28bbb | |||
| 24b005126e | |||
| ee9d65068d | |||
| 6e3d10858f | |||
| 94a82c2f6c | |||
| 81dcbbdce7 | |||
| becb9a6710 | |||
| 542744e342 | |||
| 7510e1f9af | |||
| 498250650c | |||
| ada6976e85 | |||
| 6a454e0b33 | |||
| cd6ae31d0f | |||
| 463811056d | |||
| 98d76deee5 | |||
| 20e18768eb | |||
| a34f29bcf8 | |||
| 997296e21e | |||
| 94355bf340 | |||
| 722fc87259 | |||
| 30fcbfa59f | |||
| c956ac79ed | |||
| b90f07a816 | |||
| 7d357bad87 | |||
| 3ffe76412e | |||
| 91c4a84818 | |||
| ccd433fee1 | |||
| 03f6b7c8c1 | |||
| 426ddf27ee | |||
| 4148196a36 | |||
| 9a1d753abc | |||
| 1b29abed17 | |||
| 85d72ad051 | |||
| fd3bcdf2f7 | |||
| 46afa830b3 | |||
| 7de9b2f81b | |||
| 3ec46c8631 | |||
| 529dc9c374 | |||
| 60ef920b82 | |||
| 7b7b859fee | |||
| 53d42341c0 | |||
| b5c2930609 | |||
| 3feafe38f2 | |||
| 55ffaad94f | |||
|
|
5bdde146c1 | ||
|
|
97f87fb927 | ||
|
|
469166c0bb | ||
|
|
b10965babe | ||
|
|
e5c122a2ee | ||
|
|
3aefc5c8f8 | ||
|
|
ecc643ac81 | ||
| 8e4d0defa2 | |||
| 19baed9291 | |||
| 4ea68adf8b | |||
| f459f7d39c | |||
| c3ba8ccf64 | |||
| 7ed1d89b66 | |||
| 734bcae02d | |||
| 629896b74d | |||
| 6a49539ae5 | |||
| 01ac61185b | |||
| ca01f6e729 | |||
| 0588609c75 | |||
| ab3df639b9 | |||
| fefe1f3652 | |||
| 7634df3cff | |||
| 60938af9f0 | |||
| accf0ac142 | |||
| f90badfcd5 | |||
| 249606a358 |
@@ -1,11 +1,13 @@
|
||||
name: Release — F-Droid repo + Gitea release
|
||||
name: Release — F-Droid repo + Gitea/Codeberg release
|
||||
|
||||
# A release is cut by merging a release branch into main with a bumped
|
||||
# versionName (see docs/RELEASING.md). This workflow reads that versionName and,
|
||||
# if no matching tag exists yet, runs tests, builds + signs the APK, publishes
|
||||
# it to the F-Droid repo, and only then creates the vX.Y.Z tag + Gitea release
|
||||
# itself — the tag is an output of the pipeline, not its trigger. Ordinary
|
||||
# merges (no version bump) fall through `detect` and do nothing.
|
||||
# it to the F-Droid repo, creates the vX.Y.Z tag + Gitea release, and mirrors
|
||||
# that release to Codeberg with the signed APK + a SHA-256 checksum as a
|
||||
# direct-download channel — the tag is an output of the pipeline, not its
|
||||
# trigger. Ordinary merges (no version bump) fall through `detect` and do
|
||||
# nothing.
|
||||
#
|
||||
# A manual workflow_dispatch (from a branch) runs the re-sign-only recovery
|
||||
# path: it re-signs the existing F-Droid index with the repo key and re-uploads,
|
||||
@@ -350,3 +352,88 @@ jobs:
|
||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@/tmp/$ASSET" \
|
||||
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
|
||||
|
||||
# Mirror the release to the Codeberg mirror as a direct-download channel
|
||||
# for users who don't want F-Droid. Gitea already push-mirrors branches +
|
||||
# tags to Codeberg, but releases aren't git objects so they don't sync —
|
||||
# we create the release there over the API and attach the signed APK plus
|
||||
# a SHA-256 checksum. The APK is identical to the F-Droid one (same app
|
||||
# key), so this adds no trust surface. Best-effort: a Codeberg outage
|
||||
# (it 504s under load) must never fail an already-published F-Droid
|
||||
# release. Needs the CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
|
||||
- name: Publish release to Codeberg
|
||||
if: env.IS_RELEASE == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
|
||||
API: https://codeberg.org/api/v1/repos/jlmakiola/calendula
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -e
|
||||
if [ -z "${TOKEN:-}" ]; then
|
||||
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
|
||||
exit 0
|
||||
fi
|
||||
TAG="v$VERSION"
|
||||
APK="app/build/outputs/apk/release/app-release.apk"
|
||||
if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi
|
||||
ASSET_APK="calendula_v${VERSION}.apk"
|
||||
ASSET_SUM="${ASSET_APK}.sha256"
|
||||
cp "$APK" "/tmp/$ASSET_APK"
|
||||
( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
|
||||
|
||||
# Release notes: reuse the section extracted for the Gitea release,
|
||||
# fall back to the CHANGELOG entry if that step's file is gone.
|
||||
if [ ! -s release-notes.md ]; then
|
||||
awk -v ver="$VERSION" '
|
||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
||||
/^## \[/ { flag = 0 }
|
||||
flag' CHANGELOG.md > release-notes.md
|
||||
sed -i -e '/./,$!d' release-notes.md
|
||||
fi
|
||||
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
|
||||
# The pipeline creates the tag via the Gitea API, which the push mirror
|
||||
# (sync_on_commit only fires on real git pushes) doesn't propagate
|
||||
# promptly — so a release POST that carries a target_commitish can
|
||||
# outrun the mirror and 500 on a commit/tag Codeberg hasn't received.
|
||||
# Push the tag straight to Codeberg so it's guaranteed present, then
|
||||
# attach the release to that existing tag with NO target_commitish
|
||||
# (which is what triggered the 500).
|
||||
git tag -f "$TAG" "$SHA"
|
||||
git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/calendula.git" \
|
||||
"refs/tags/$TAG"
|
||||
python3 - "$TAG" <<'PY' > cb-payload.json
|
||||
import json, sys
|
||||
print(json.dumps({
|
||||
"tag_name": sys.argv[1],
|
||||
"name": sys.argv[1],
|
||||
"body": open("release-notes.md").read(),
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}))
|
||||
PY
|
||||
# Upsert (re-run safe). POST also creates the tag at target_commitish
|
||||
# if the push mirror hasn't synced it yet.
|
||||
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
||||
if [ -n "$ID" ]; then
|
||||
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d @cb-payload.json "$API/releases/$ID"
|
||||
else
|
||||
curl -s -o cb-response.json -w "release POST HTTP %{http_code}\n" -X POST \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d @cb-payload.json "$API/releases"
|
||||
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id." >&2; exit 1; fi
|
||||
|
||||
# Attach APK + checksum, replacing any prior asset of the same name.
|
||||
for A in "$ASSET_APK" "$ASSET_SUM"; do
|
||||
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
|
||||
| jq -r --arg n "$A" '.[] | select(.name==$n) | .id')
|
||||
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
|
||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@/tmp/$A" \
|
||||
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
|
||||
done
|
||||
echo "Published $TAG to Codeberg."
|
||||
|
||||
115
CHANGELOG.md
115
CHANGELOG.md
@@ -5,6 +5,111 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.16.0] — 2026-07-24
|
||||
|
||||
### Added
|
||||
- Choose how the month view is laid out. A new **Month view style** setting
|
||||
(Settings → Views) offers three ways to read a month, each shown with a
|
||||
preview of the layout it produces:
|
||||
- **Pages** — what you have today: one month at a time, swiped sideways.
|
||||
- **Continuous** — scroll up and down through the weeks without a break
|
||||
between months. Because the weeks run on unbroken, no month is cut off and
|
||||
no day appears twice, where paging repeats a boundary week at the end of one
|
||||
month and the start of the next. The 1st of each month names itself so you
|
||||
always know where you are, and the title bar keeps up as you scroll ([#38]).
|
||||
- **Split** — a compact grid showing coloured dots for the days that have
|
||||
something on them, with the day you tap listed in full underneath. Tap the
|
||||
date above the list to open the whole day ([#53]).
|
||||
|
||||
The Agenda view is untouched by this and stays available in all three styles —
|
||||
the split layout lists a single day, while Agenda remains a rolling multi-day
|
||||
window with its own range settings.
|
||||
- Give an event its own time zone. A new **Time zone** field (under "more
|
||||
fields" in the event form) pins an event to a specific zone, so a call set for
|
||||
8:00 AM in New York stays 8:00 AM in New York wherever you open it — and keeps
|
||||
tracking that zone across daylight-saving changes instead of drifting an hour.
|
||||
The form edits the event in its own zone and shows the local equivalent under
|
||||
the times ("2:00 PM – 3:00 PM your time"); the event's details keep your local
|
||||
time first and note the original beneath it, so both are always clear. Pick a
|
||||
zone from a full-screen picker with your device zone and recent choices on top,
|
||||
searching by city ("new york"), IANA id ("europe/berlin"), or abbreviation
|
||||
("CEST") to gather every matching zone at once. All-day events stay
|
||||
date-anchored and carry no zone, as before ([#31]).
|
||||
- Put the "jump to today" button in the toolbar. A new **Today button in
|
||||
toolbar** setting (Settings → Appearance, off by default) swaps the floating
|
||||
button that fades into the corner while you're away from today for a permanent
|
||||
today icon in the top bar — always there, on today or not, matching the
|
||||
familiar calendar-app pattern. Leave it off to keep the floating button as
|
||||
before ([#60]).
|
||||
- Choose what Calendula calls itself on your home screen. A new **App name**
|
||||
setting (Settings → Appearance) switches the launcher label between
|
||||
**Calendula** and **Calendar**, for launchers that can't rename apps
|
||||
themselves. Pick from a full-screen chooser that previews both names as
|
||||
launcher marks; the change applies at once ([#44]).
|
||||
- Calendula now speaks **Arabic**, laid out right-to-left, and its French and
|
||||
Italian translations have been brought up to date — thanks to the community
|
||||
translators on Weblate. Pick a language under Settings → Language (or leave it
|
||||
on the system default).
|
||||
|
||||
### Changed
|
||||
- Dates in the Month, Week and Day title bars now follow your language and
|
||||
region instead of one hardcoded layout. Every date was rendered in a fixed
|
||||
German-style order with a trailing dot on the day number, whatever your
|
||||
settings: US English showed "Fri, 17. Jul 2026" where it should read
|
||||
"Fri, Jul 17". The Agenda view already formatted correctly, so the two
|
||||
disagreed about the same date. All four views now share one formatter, and the
|
||||
day/month order, the separators and the ordinal all come from your locale —
|
||||
so English-in-Germany reads "Fri, 17 Jul" and English-in-the-US "Fri, Jul 17",
|
||||
each correct for where you are ([#60]).
|
||||
- The title bar drops the year while you're in the current one — "July" rather
|
||||
than "July 2026". The year reappears the moment you page out of the current
|
||||
year, which is when it tells you something you didn't already know.
|
||||
- The Week view's title now names the month instead of spelling out the day range.
|
||||
"24. Jun – 31. Jun" restated the day numbers already printed in the column
|
||||
headers right below it, in the widest string in the bar. A week that straddles
|
||||
two months keeps the outgoing month until it is fully gone ([#60]).
|
||||
- The custom recurrence picker has been redesigned and tightened up. As you
|
||||
build a rule — "every 2 weeks on Mon & Wed, until a date" — the live summary
|
||||
now describes exactly what will be saved rather than a near-copy that could
|
||||
drift from it, the amount fields accept being left blank (reading as their
|
||||
shown default instead of greying out OK), and the read-out no longer jumps
|
||||
around as you tap weekdays ([#42]).
|
||||
|
||||
### Fixed
|
||||
- An all-day event no longer shows up again the day after it happened. In time
|
||||
zones east of UTC, an all-day event — a birthday, say — set for one day also
|
||||
appeared under the *next* day's heading in the Agenda (and the agenda widget),
|
||||
because all-day events are anchored to UTC midnight and the following day's
|
||||
window reached back across that boundary and pulled the event forward onto
|
||||
"today". Each all-day event now lists only on the day it actually falls on
|
||||
([#65]).
|
||||
- A multi-day event now shows under every day it spans in the Agenda, not just
|
||||
its first day, so a trip or a multi-day booking appears on each day it covers.
|
||||
- The "Upcoming" agenda widget now scales its text and rows to the size you give
|
||||
it. Previously it was laid out once for the smallest size and simply stretched
|
||||
when enlarged, so the text stayed small no matter how big you made the widget.
|
||||
Now a bigger widget gets bigger, more readable type and roomier rows, while the
|
||||
default size looks exactly as before — no new setting; it follows the size you
|
||||
already chose ([#51]).
|
||||
- Calendula now appears under other apps' "Add to calendar" / "Save to calendar"
|
||||
actions. Some apps (e.g. DB Navigator) fire the widely-used "insert event"
|
||||
intent with the singular `vnd.android.cursor.item/event` type, which Calendula
|
||||
didn't advertise — so it was left out of the chooser, and if it was your only
|
||||
calendar app the save silently did nothing. It now accepts that form, plus the
|
||||
`INSERT_OR_EDIT` action, and opens the new event prefilled for review ([#74]).
|
||||
- Opening a `.ics`/`.vcs` file now works even when another app hands it over
|
||||
mislabelled as a generic download (`application/octet-stream`), as some mail
|
||||
clients, browsers and file managers do — Calendula recognises it by its file
|
||||
extension instead of relying on the declared type ([#74]).
|
||||
- A recurrence end date no longer lands a day late. West of UTC, setting a rule
|
||||
to end "until" a given day could save and show the day after the one picked;
|
||||
the end date now reads back as chosen ([#42]).
|
||||
- The status- and navigation-bar icons stay legible over full-screen pickers in
|
||||
dark theme. They could render dark-on-dark — a near-invisible black clock
|
||||
against the dark picker — instead of switching to light ([#70]).
|
||||
|
||||
## [2.15.0] — 2026-07-15
|
||||
|
||||
### Added
|
||||
@@ -984,6 +1089,7 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
|
||||
[#21]: https://codeberg.org/jlmakiola/calendula/issues/21
|
||||
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
|
||||
[#31]: https://codeberg.org/jlmakiola/calendula/issues/31
|
||||
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32
|
||||
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
||||
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
||||
@@ -996,4 +1102,13 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
|
||||
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
|
||||
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
||||
[#51]: https://codeberg.org/jlmakiola/calendula/issues/51
|
||||
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52
|
||||
[#38]: https://codeberg.org/jlmakiola/calendula/issues/38
|
||||
[#53]: https://codeberg.org/jlmakiola/calendula/issues/53
|
||||
[#60]: https://codeberg.org/jlmakiola/calendula/issues/60
|
||||
[#65]: https://codeberg.org/jlmakiola/calendula/issues/65
|
||||
[#74]: https://codeberg.org/jlmakiola/calendula/issues/74
|
||||
[#42]: https://codeberg.org/jlmakiola/calendula/issues/42
|
||||
[#44]: https://codeberg.org/jlmakiola/calendula/issues/44
|
||||
[#70]: https://codeberg.org/jlmakiola/calendula/issues/70
|
||||
|
||||
@@ -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 = 21500
|
||||
versionName = "2.15.0"
|
||||
versionCode = 21600
|
||||
versionName = "2.16.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -113,10 +113,13 @@ android {
|
||||
lint {
|
||||
// Community translations are expected to be partial — a missing string
|
||||
// falls back to the English base at runtime — so don't fail the build on
|
||||
// it. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
|
||||
// it. Likewise a translated <plurals> may not fill every CLDR quantity
|
||||
// form its locale defines (e.g. Arabic needs "zero"); the missing form
|
||||
// falls back to "other" at runtime, so MissingQuantity is informational
|
||||
// too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
|
||||
// check_translations.py guards the same invariants with clearer,
|
||||
// translator-facing messages.
|
||||
informational += "MissingTranslation"
|
||||
informational += listOf("MissingTranslation", "MissingQuantity")
|
||||
}
|
||||
|
||||
testOptions {
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -31,7 +32,12 @@ class CalendarRepositorySmokeTest {
|
||||
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||
produceFile = { context.cacheDir.resolve("smoke_test_prefs.preferences_pb") },
|
||||
)
|
||||
return CalendarRepositoryImpl(dataSource, CalendarPrefs(store), Dispatchers.IO)
|
||||
return CalendarRepositoryImpl(
|
||||
dataSource,
|
||||
CalendarPrefs(store),
|
||||
SettingsPrefs(store),
|
||||
Dispatchers.IO,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -63,10 +63,9 @@
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- The MAIN/LAUNCHER entry (the launcher icon + its label) lives on
|
||||
the two <activity-alias> below, so the app name can be switched at
|
||||
runtime (issue #44). MainActivity keeps every other filter. -->
|
||||
|
||||
<!-- Be selectable as the system calendar app. Android has no API for
|
||||
an app to make itself the default, so registering the filters
|
||||
@@ -113,6 +112,28 @@
|
||||
<data android:mimeType="text/x-vcalendar" />
|
||||
<data android:mimeType="application/ics" />
|
||||
</intent-filter>
|
||||
<!-- Same .ics/.vcs data arriving mislabelled as a generic download —
|
||||
application/octet-stream — the way many mail clients, browsers and
|
||||
file managers hand off attachments. Matched by file extension, so
|
||||
this stays a separate filter: a pathPattern here must not narrow
|
||||
the MIME-typed VIEW filter above (that one has no path and must
|
||||
keep matching regardless of name). The import handler ignores the
|
||||
MIME type, so a let-through octet-stream .ics imports normally.
|
||||
Best-effort: pathPattern is reliable for file:// (and content://
|
||||
whose path carries the filename); content:// URIs that expose no
|
||||
name still fall back to the MIME-typed filter above. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="content" />
|
||||
<data android:scheme="file" />
|
||||
<data android:host="*" />
|
||||
<data android:mimeType="application/octet-stream" />
|
||||
<data android:pathPattern=".*\\.ics" />
|
||||
<data android:pathPattern=".*\\.vcs" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Receive a .ics/.vcs shared from another app (same MIME set). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
@@ -124,11 +145,13 @@
|
||||
|
||||
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
|
||||
to create a new event, the way the AOSP calendar accepts it:
|
||||
ACTION_INSERT on the events dir mime type, carrying the new
|
||||
ACTION_INSERT on the events *dir* mime type, carrying the new
|
||||
event's fields as CalendarContract extras
|
||||
(MainActivity.insertFormOrNull, issue #30). ACTION_EDIT on the
|
||||
dir mime is AOSP's "edit a new event" — i.e. create — so it maps
|
||||
to the same prefilled create form. -->
|
||||
to the same prefilled create form. (The far more common *item*-
|
||||
typed INSERT — the form the Android docs' example and apps like
|
||||
DB Navigator use — is the item filter below, issue #74.) -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.INSERT" />
|
||||
<action android:name="android.intent.action.EDIT" />
|
||||
@@ -136,14 +159,31 @@
|
||||
<data android:mimeType="vnd.android.cursor.dir/event" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Edit an existing event another app/assistant/widget points at:
|
||||
ACTION_EDIT on content://com.android.calendar/events/<id>, the way
|
||||
AOSP fires it. Opens the occurrence in the edit form (not the
|
||||
read-only detail — that's the VIEW filter above). Matched by the
|
||||
provider's item MIME type, like the VIEW filter. The occurrence's
|
||||
times ride as EXTRA_EVENT_BEGIN_TIME / EXTRA_EVENT_END_TIME when
|
||||
supplied (MainActivity.editEventKeyOrNull). -->
|
||||
<!-- Create or edit an event another app/assistant/widget points at,
|
||||
addressed by the provider's *item* MIME type. Three actions share
|
||||
this filter, told apart at runtime by the intent's data:
|
||||
|
||||
• ACTION_INSERT — create. This is the form the Android docs'
|
||||
"insert an event" example and many apps use
|
||||
(setType("vnd.android.cursor.item/event")), e.g. DB Navigator's
|
||||
"Save to calendar". The dir-typed filter above alone missed it,
|
||||
so Calendula never showed in the chooser — and, when it was the
|
||||
only calendar app, the intent resolved to nothing (issue #74).
|
||||
• ACTION_INSERT_OR_EDIT — the third "add to calendar" action AOSP
|
||||
and Google Calendar register; a create, or an edit when it
|
||||
carries an event id.
|
||||
• ACTION_EDIT — edit the existing event at
|
||||
content://com.android.calendar/events/<id> (an id-less EDIT is a
|
||||
create). Opens the occurrence in the edit form, not the
|
||||
read-only detail — that's the VIEW filter above.
|
||||
|
||||
Create fields ride as CalendarContract extras; an edit's
|
||||
occurrence times ride as EXTRA_EVENT_BEGIN_TIME /
|
||||
EXTRA_EVENT_END_TIME when supplied (MainActivity.insertFormOrNull
|
||||
/ editEventKeyOrNull). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.INSERT" />
|
||||
<action android:name="android.intent.action.INSERT_OR_EDIT" />
|
||||
<action android:name="android.intent.action.EDIT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="vnd.android.cursor.item/event" />
|
||||
@@ -165,11 +205,47 @@
|
||||
<data android:mimeType="vnd.android.cursor.item/event" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
||||
</activity>
|
||||
|
||||
<!-- Launcher entry for MainActivity, split into two aliases so the app's
|
||||
launcher name can be switched at runtime between "Calendula" and
|
||||
"Calendar" (issue #44). Exactly one is enabled at a time; the app
|
||||
flips them via PackageManager.setComponentEnabledSetting
|
||||
(LauncherNameManager). The shortcuts meta-data lives here, not on
|
||||
MainActivity, because static shortcuts are published by whichever
|
||||
component owns the MAIN/LAUNCHER filter. -->
|
||||
<activity-alias
|
||||
android:name=".DefaultNameAlias"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:targetActivity=".MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
</activity>
|
||||
</activity-alias>
|
||||
<activity-alias
|
||||
android:name=".CalendarNameAlias"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name_calendar_alias"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:targetActivity=".MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
</activity-alias>
|
||||
|
||||
<!-- Standalone surface for a captured crash report. MainActivity routes
|
||||
here on a startup crash-loop, so it stays clear of the app's Hilt
|
||||
|
||||
@@ -222,10 +222,12 @@ class MainActivity : AppCompatActivity() {
|
||||
* [buildInsertEventForm].
|
||||
*/
|
||||
private fun Intent.insertFormOrNull(): EventForm? {
|
||||
// ACTION_EDIT on an existing event routes to the edit form instead
|
||||
// ([editEventKeyOrNull]); only an id-less EDIT is a create.
|
||||
// ACTION_EDIT / ACTION_INSERT_OR_EDIT on an existing event route to the
|
||||
// edit form instead ([editEventKeyOrNull]); an id-less one is a create,
|
||||
// as is any plain ACTION_INSERT.
|
||||
val isCreate = action == Intent.ACTION_INSERT ||
|
||||
(action == Intent.ACTION_EDIT && editEventKeyOrNull() == null)
|
||||
((action == Intent.ACTION_EDIT || action == Intent.ACTION_INSERT_OR_EDIT) &&
|
||||
editEventKeyOrNull() == null)
|
||||
if (!isCreate) return null
|
||||
return buildInsertEventForm(
|
||||
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
||||
@@ -344,10 +346,11 @@ class MainActivity : AppCompatActivity() {
|
||||
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME`
|
||||
* when it has them, otherwise we carry [NO_OCCURRENCE_TIME] and
|
||||
* [EventEditViewModel.openForEdit] falls back to the event row's own
|
||||
* DTSTART/DTEND. An id-less `ACTION_EDIT` is a create instead ([insertFormOrNull]).
|
||||
* DTSTART/DTEND. An id-less `ACTION_EDIT` — or `ACTION_INSERT_OR_EDIT`, which
|
||||
* some apps fire — is a create instead ([insertFormOrNull]).
|
||||
*/
|
||||
private fun Intent.editEventKeyOrNull(): LongArray? {
|
||||
if (action != Intent.ACTION_EDIT) return null
|
||||
if (action != Intent.ACTION_EDIT && action != Intent.ACTION_INSERT_OR_EDIT) return null
|
||||
val uri = data ?: return null
|
||||
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
||||
val segments = uri.pathSegments
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package de.jeanlucmakiola.calendula.data.appname
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** The launcher label the app shows for itself (issue #44). */
|
||||
enum class LauncherName { CALENDULA, CALENDAR }
|
||||
|
||||
/** The two launcher aliases declared in the manifest. */
|
||||
enum class LauncherAlias { DEFAULT, CALENDAR }
|
||||
|
||||
/** One component-enable change in a [aliasWritePlan]. */
|
||||
data class AliasStateChange(val alias: LauncherAlias, val enabled: Boolean)
|
||||
|
||||
/**
|
||||
* Interpret the `CalendarNameAlias` component-enabled state as a [LauncherName].
|
||||
* Only [PackageManager.COMPONENT_ENABLED_STATE_ENABLED] means the "Calendar"
|
||||
* name is active; `DEFAULT` (never toggled) and `DISABLED` both resolve to the
|
||||
* manifest default, "Calendula".
|
||||
*/
|
||||
fun launcherNameFor(calendarAliasState: Int): LauncherName =
|
||||
if (calendarAliasState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
|
||||
LauncherName.CALENDAR
|
||||
} else {
|
||||
LauncherName.CALENDULA
|
||||
}
|
||||
|
||||
/**
|
||||
* The ordered enable/disable steps to switch the launcher name to [target].
|
||||
* Always **enables the target alias first, then disables the other** — the two
|
||||
* `setComponentEnabledSetting` calls are not atomic, and this ordering means the
|
||||
* only transient the launcher can observe is a harmless two-entry state, never a
|
||||
* zero-entry one (which would briefly drop the app from the launcher).
|
||||
*/
|
||||
fun aliasWritePlan(target: LauncherName): List<AliasStateChange> = when (target) {
|
||||
LauncherName.CALENDAR -> listOf(
|
||||
AliasStateChange(LauncherAlias.CALENDAR, enabled = true),
|
||||
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
|
||||
)
|
||||
LauncherName.CALENDULA -> listOf(
|
||||
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
|
||||
AliasStateChange(LauncherAlias.CALENDAR, enabled = false),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the app's launcher label between "Calendula" and "Calendar" by
|
||||
* enabling/disabling the two `<activity-alias>` components (issue #44). The
|
||||
* component-enabled state is the single source of truth — there is no persisted
|
||||
* preference — so [current] reads it straight from [PackageManager] and the two
|
||||
* always agree.
|
||||
*
|
||||
* The decision logic lives in the pure [launcherNameFor] / [aliasWritePlan]
|
||||
* functions above (JVM-tested); this class is only the thin framework seam.
|
||||
*/
|
||||
@Singleton
|
||||
class LauncherNameManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val packageManager: PackageManager get() = context.packageManager
|
||||
|
||||
// The class portion is namespace-qualified (suffix-free), the package portion
|
||||
// is the applicationId (which carries the .debug / .releasetest suffix). The
|
||||
// manifest's ".CalendarNameAlias" resolves its class against the namespace, so
|
||||
// the real component is <namespace>.CalendarNameAlias registered under the
|
||||
// suffixed applicationId. Do NOT use ComponentName(context, ".CalendarNameAlias")
|
||||
// — its leading-dot form prepends the applicationId to the class too, producing
|
||||
// "<appId>.CalendarNameAlias" and failing on every debug / releaseTest build.
|
||||
private fun component(alias: LauncherAlias): ComponentName {
|
||||
val simpleName = when (alias) {
|
||||
LauncherAlias.DEFAULT -> "DefaultNameAlias"
|
||||
LauncherAlias.CALENDAR -> "CalendarNameAlias"
|
||||
}
|
||||
return ComponentName(context.packageName, "$NAMESPACE.$simpleName")
|
||||
}
|
||||
|
||||
/** The launcher name currently in effect. */
|
||||
fun current(): LauncherName =
|
||||
launcherNameFor(packageManager.getComponentEnabledSetting(component(LauncherAlias.CALENDAR)))
|
||||
|
||||
/** Switch the launcher name to [name] (no-op cost if already active). */
|
||||
fun set(name: LauncherName) {
|
||||
for (change in aliasWritePlan(name)) {
|
||||
val state = if (change.enabled) {
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
} else {
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||
}
|
||||
packageManager.setComponentEnabledSetting(
|
||||
component(change.alias),
|
||||
state,
|
||||
PackageManager.DONT_KILL_APP,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* The app's namespace (R-class package) — NOT `applicationId`, which
|
||||
* carries the build-type suffix. Kept in sync with `namespace` in
|
||||
* `app/build.gradle.kts`.
|
||||
*/
|
||||
const val NAMESPACE = "de.jeanlucmakiola.calendula"
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,11 @@ import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toJavaLocalDateTime
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.LocalDateTime as JavaLocalDateTime
|
||||
|
||||
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
||||
internal data class EventWriteTimes(
|
||||
@@ -20,7 +22,11 @@ internal data class EventWriteTimes(
|
||||
/**
|
||||
* All-day events live at UTC midnights with an exclusive DTEND (the
|
||||
* CalendarContract convention — a one-day event ends at the next midnight);
|
||||
* timed events resolve their wall-clock values in [zone].
|
||||
* timed events resolve their wall-clock values in the form's own
|
||||
* [EventForm.timezone], falling back to [zone] (the device) when it doesn't pin
|
||||
* one. Passing the device zone is therefore still correct for an unpinned form —
|
||||
* but it no longer overrides a pinned event's zone, which is what used to
|
||||
* silently re-anchor a foreign-zone event to the device on any time edit.
|
||||
*/
|
||||
internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) {
|
||||
EventWriteTimes(
|
||||
@@ -31,13 +37,38 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
||||
timezone = "UTC",
|
||||
)
|
||||
} else {
|
||||
val writeZone = writeZone(zone)
|
||||
EventWriteTimes(
|
||||
dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
||||
dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
||||
timezone = zone.id,
|
||||
dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||
dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||
timezone = writeZone.id,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The zone this form's DTSTART is expressed in: UTC for an all-day event (the
|
||||
* provider's date anchor), otherwise the form's own pinned zone, falling back to
|
||||
* [deviceZone] when it doesn't pin one or pins something the tz database can't
|
||||
* parse.
|
||||
*/
|
||||
private fun EventForm.writeZone(deviceZone: ZoneId): ZoneId = if (isAllDay) {
|
||||
ZoneOffset.UTC
|
||||
} else {
|
||||
timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: deviceZone
|
||||
}
|
||||
|
||||
/**
|
||||
* The form's start as a bare wall-clock value — what the user sees on the form,
|
||||
* stripped of any zone. All-day events use their date's midnight rather than
|
||||
* [EventForm.start]'s placeholder time-of-day, which exists only so switching the
|
||||
* event back to timed has something to show.
|
||||
*/
|
||||
private fun EventForm.anchorLocal(): JavaLocalDateTime = if (isAllDay) {
|
||||
start.date.toJavaLocalDate().atStartOfDay()
|
||||
} else {
|
||||
start.toJavaLocalDateTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 2445 duration for a recurring event's row (the provider requires
|
||||
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
|
||||
@@ -104,10 +135,12 @@ internal fun buildEventInsertValues(
|
||||
* Time fields travel together (the provider validates them as a unit):
|
||||
* - unchanged times, all-day flag and rrule → no time columns at all;
|
||||
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
|
||||
* - recurring result → the *series* DTSTART moves by the same delta the user
|
||||
* applied to the displayed occurrence ([seriesDtStartMillis] is the row's
|
||||
* current DTSTART), DURATION replaces DTEND, RRULE is written. This keeps
|
||||
* past occurrences intact when someone edits a later occurrence's time.
|
||||
* - recurring result → the *series* DTSTART moves by the same **wall-clock**
|
||||
* shift the user applied to the displayed occurrence and is re-resolved in the
|
||||
* event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION
|
||||
* replaces DTEND, RRULE is written. This keeps past occurrences intact when
|
||||
* someone edits a later occurrence's time, and keeps the anchor's time-of-day
|
||||
* stable across a DST boundary or a zone change between the two.
|
||||
*/
|
||||
internal fun buildEventUpdateValues(
|
||||
original: EventForm,
|
||||
@@ -134,10 +167,14 @@ internal fun buildEventUpdateValues(
|
||||
putAll(eventColorColumns(updated.colorKey, updated.color))
|
||||
}
|
||||
|
||||
// A zone change counts as a time change even when the wall-clock is
|
||||
// untouched: the same 09:00 in another zone is a different instant, so
|
||||
// DTSTART has to move with it.
|
||||
val timesChanged = updated.start != original.start ||
|
||||
updated.end != original.end ||
|
||||
updated.isAllDay != original.isAllDay ||
|
||||
updated.rrule != original.rrule
|
||||
updated.rrule != original.rrule ||
|
||||
updated.timezone != original.timezone
|
||||
if (!timesChanged) return@buildMap
|
||||
|
||||
val newTimes = updated.toWriteTimes(zone)
|
||||
@@ -149,8 +186,23 @@ internal fun buildEventUpdateValues(
|
||||
put(CalendarContract.Events.RRULE, null)
|
||||
put(CalendarContract.Events.DURATION, null)
|
||||
} else {
|
||||
val startDelta = newTimes.dtStartMillis - original.toWriteTimes(zone).dtStartMillis
|
||||
put(CalendarContract.Events.DTSTART, seriesDtStartMillis + startDelta)
|
||||
// Move the series anchor by the *wall-clock* shift the user applied to the
|
||||
// displayed occurrence, then re-resolve it in the event's (possibly new)
|
||||
// zone — never by a millisecond delta. An instant delta silently bakes in
|
||||
// the offset that happened to apply on the edited occurrence's date, which
|
||||
// is a different offset from the series anchor's whenever a DST boundary
|
||||
// sits between them, or whenever the zone itself changed. Working in wall
|
||||
// clock keeps "09:00" meaning 09:00 at both ends.
|
||||
val seriesLocal = Instant.ofEpochMilli(seriesDtStartMillis)
|
||||
.atZone(original.writeZone(zone)).toLocalDateTime()
|
||||
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
|
||||
val shifted = seriesLocal.plus(wallClockShift)
|
||||
// An all-day series anchor must sit on a UTC midnight. A pure day move
|
||||
// already lands there (both ends are midnights), but *switching* a
|
||||
// recurring event to all-day shifts by a time-of-day too, so snap.
|
||||
val newSeriesLocal = if (updated.isAllDay) shifted.toLocalDate().atStartOfDay() else shifted
|
||||
val newSeriesStart = newSeriesLocal.atZone(updated.writeZone(zone))
|
||||
put(CalendarContract.Events.DTSTART, newSeriesStart.toInstant().toEpochMilli())
|
||||
put(CalendarContract.Events.DTEND, null)
|
||||
put(CalendarContract.Events.RRULE, updated.rrule)
|
||||
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
|
||||
|
||||
@@ -21,9 +21,15 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||
import java.time.ZoneId
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import java.time.temporal.WeekFields
|
||||
import java.util.Locale
|
||||
@@ -81,6 +87,24 @@ fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
|
||||
WeekStartPref.Auto -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolved first day of the week as a hot [StateFlow] — the one shape every
|
||||
* screen that orders or lays out weekdays should use (the month grid, the agenda
|
||||
* "this week" range, the recurrence weekday toggles). Sharing it keeps the
|
||||
* initial-frame value identical everywhere; hand-rolled copies had drifted onto
|
||||
* different `initialValue`s, so two surfaces could disagree for a frame.
|
||||
*/
|
||||
fun SettingsPrefs.firstDayOfWeek(scope: CoroutineScope): StateFlow<DayOfWeek> =
|
||||
weekStart
|
||||
.map { it.resolveFirstDay(Locale.getDefault()) }
|
||||
.stateIn(
|
||||
scope = scope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
// Seed with the locale convention rather than a hardcoded weekday, so
|
||||
// the first frame is already right for everyone still on Auto.
|
||||
initialValue = WeekStartPref.Auto.resolveFirstDay(Locale.getDefault()),
|
||||
)
|
||||
|
||||
/**
|
||||
* Display settings (M4) persisted app-side: theme override, Material You
|
||||
* dynamic colour, and week start. Language is handled separately through
|
||||
@@ -233,6 +257,33 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[SHOW_WEEK_NUMBERS_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How the Month view lays itself out (#38, #53). Defaults to [MonthViewStyle.Paged]
|
||||
* — the historical behaviour, so existing installs see no change until they opt in.
|
||||
*/
|
||||
val monthViewStyle: Flow<MonthViewStyle> = store.data.map { prefs ->
|
||||
prefs[MONTH_VIEW_STYLE_KEY].toEnum(MonthViewStyle.Paged)
|
||||
}
|
||||
|
||||
suspend fun setMonthViewStyle(style: MonthViewStyle) {
|
||||
store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the jump-to-today control lives (issue #60). Default OFF — the
|
||||
* historical layout, where it's an extended FAB that fades in above the "+"
|
||||
* only while the view is off today. Turning it ON moves it to a persistent
|
||||
* icon button in each calendar view's top bar (always shown, on today or
|
||||
* not) and drops the FAB pill.
|
||||
*/
|
||||
val todayButtonInToolbar: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[TODAY_BUTTON_IN_TOOLBAR_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setTodayButtonInToolbar(enabled: Boolean) {
|
||||
store.edit { it[TODAY_BUTTON_IN_TOOLBAR_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
|
||||
@@ -354,6 +405,26 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zones the user picked recently, most recent first — the timezone picker's
|
||||
* default list, since scrolling ~600 zones to re-find the two you actually
|
||||
* use is the whole problem. Stored comma-joined (IANA ids never contain a
|
||||
* comma); unparseable ids are dropped on read, so a zone the tz database
|
||||
* later retires can't wedge the list.
|
||||
*/
|
||||
val recentTimeZones: Flow<List<String>> = store.data.map { prefs ->
|
||||
parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY])
|
||||
}
|
||||
|
||||
suspend fun addRecentTimeZone(zoneId: String) {
|
||||
store.edit { prefs ->
|
||||
val updated = (listOf(zoneId) + parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY]))
|
||||
.distinct()
|
||||
.take(MAX_RECENT_TIME_ZONES)
|
||||
prefs[RECENT_TIMEZONES_KEY] = updated.joinToString(",")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether opening the new-event form focuses the title field and raises the
|
||||
* keyboard straight away (issue #10). Default ON — a new event almost always
|
||||
@@ -650,6 +721,13 @@ class SettingsPrefs @Inject constructor(
|
||||
private fun titleTemplateKey(type: SpecialDateType) =
|
||||
stringPreferencesKey("special_dates_title_${type.name}")
|
||||
|
||||
private fun parseRecentTimeZones(stored: String?): List<String> =
|
||||
stored?.split(',').orEmpty()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && runCatching { ZoneId.of(it) }.isSuccess }
|
||||
.distinct()
|
||||
.take(MAX_RECENT_TIME_ZONES)
|
||||
|
||||
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||
null -> DEFAULT_FORM_FIELDS
|
||||
else -> stored.split(',')
|
||||
@@ -710,6 +788,8 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
|
||||
internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style")
|
||||
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
|
||||
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")
|
||||
@@ -736,6 +816,10 @@ class SettingsPrefs @Inject constructor(
|
||||
stringPreferencesKey("per_calendar_allday_reminder_override")
|
||||
internal val DEFAULT_FORM_FIELDS =
|
||||
setOf(EventFormField.Location, EventFormField.Description)
|
||||
internal val RECENT_TIMEZONES_KEY = stringPreferencesKey("recent_time_zones")
|
||||
|
||||
/** Enough to cover the zones a user actually recurs to, without a wall of rows. */
|
||||
internal const val MAX_RECENT_TIME_ZONES = 5
|
||||
internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled")
|
||||
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
|
||||
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")
|
||||
|
||||
@@ -4,13 +4,14 @@ 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.time.Instant
|
||||
|
||||
/**
|
||||
* User input for creating an event (and, from v1.3, editing one). Times are
|
||||
* wall-clock values in the device zone; the data layer translates them to
|
||||
* provider millis (all-day events normalise to UTC midnights there).
|
||||
* wall-clock values in [timezone]; the data layer translates them to provider
|
||||
* millis (all-day events normalise to UTC midnights there).
|
||||
*/
|
||||
data class EventForm(
|
||||
val calendarId: Long?,
|
||||
@@ -18,6 +19,23 @@ data class EventForm(
|
||||
val isAllDay: Boolean = false,
|
||||
val start: LocalDateTime,
|
||||
val end: LocalDateTime,
|
||||
/**
|
||||
* The zone [start]/[end] are wall-clock values in, or null to follow the
|
||||
* device — null is not "no zone", it is "whichever zone the device is in
|
||||
* when this is saved", which is what an event authored and lived in one
|
||||
* place wants. The data layer resolves it at write time and always stamps a
|
||||
* concrete `EVENT_TIMEZONE`.
|
||||
*
|
||||
* A non-null value pins the event to a zone regardless of where the device
|
||||
* is, so it keeps tracking that zone's offset across DST. [toEditForm] only
|
||||
* sets it when the stored zone differs from the device's, so merely opening
|
||||
* a local event never reveals the field — and re-opening a pinned one in
|
||||
* another zone round-trips it rather than silently re-anchoring it.
|
||||
*
|
||||
* Always null for all-day events: those are date-anchored, not zone-anchored
|
||||
* (see [EventFormField.Timezone] and the data layer's UTC-midnight rule).
|
||||
*/
|
||||
val timezone: String? = null,
|
||||
val location: String = "",
|
||||
val description: String = "",
|
||||
/** Reminder lead times in minutes before the start, deduplicated. */
|
||||
@@ -66,11 +84,19 @@ data class EventAttendee(
|
||||
|
||||
/**
|
||||
* The form's optional sections. Which ones show by default is a user setting;
|
||||
* the rest unfold behind a "more fields" button.
|
||||
* the rest unfold behind a "more fields" button. Declaration order is the order
|
||||
* they're offered in, so a new constant goes where it belongs on the form, not
|
||||
* at the end.
|
||||
*/
|
||||
enum class EventFormField {
|
||||
Location,
|
||||
Description,
|
||||
/**
|
||||
* Pins the event's wall-clock times to a zone. Offered right after the time
|
||||
* fields it qualifies, and suppressed entirely for all-day events, whose
|
||||
* dates are deliberately zone-free.
|
||||
*/
|
||||
Timezone,
|
||||
Reminders,
|
||||
Recurrence,
|
||||
Availability,
|
||||
@@ -100,8 +126,25 @@ enum class EventFormProblem {
|
||||
* All-day provider times are UTC midnights with an exclusive end; the form
|
||||
* shows the last covered day and keeps placeholder wall-clock times in case
|
||||
* the user switches the event to timed.
|
||||
*
|
||||
* A timed event stored in a zone other than [zone] is prefilled *in its own
|
||||
* zone* and keeps it pinned, so the wall-clock the form shows is the one the
|
||||
* event means ("the New York 09:00 call") and a later save re-anchors it to the
|
||||
* same zone rather than the device's.
|
||||
*/
|
||||
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
|
||||
// All-day events are date-anchored and carry a nominal "UTC" that is an
|
||||
// anchor, not a location, so they never pin a zone.
|
||||
val pinnedZone = if (instance.isAllDay) {
|
||||
null
|
||||
} else {
|
||||
eventTimezone
|
||||
?.takeIf { it != zone.id }
|
||||
// An unparseable id (a malformed sync row) can't be honoured or
|
||||
// shown; fall back to the device zone rather than failing the open.
|
||||
?.takeIf { runCatching { TimeZone.of(it) }.isSuccess }
|
||||
}
|
||||
val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone
|
||||
val (start, end) = if (instance.isAllDay) {
|
||||
val startDate = Instant.fromEpochMilliseconds(beginMillis)
|
||||
.toLocalDateTime(TimeZone.UTC).date
|
||||
@@ -110,8 +153,8 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||
} else {
|
||||
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(zone) to
|
||||
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone)
|
||||
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to
|
||||
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
|
||||
}
|
||||
return EventForm(
|
||||
calendarId = instance.calendarId,
|
||||
@@ -119,6 +162,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
isAllDay = instance.isAllDay,
|
||||
start = start,
|
||||
end = end,
|
||||
timezone = pinnedZone,
|
||||
location = instance.location.orEmpty(),
|
||||
description = description.orEmpty(),
|
||||
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
||||
@@ -176,6 +220,23 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
||||
rowEnd = instance.end,
|
||||
)
|
||||
|
||||
/**
|
||||
* The form's times as they land in [target] — what a pinned event's wall-clock
|
||||
* actually means where the user is standing. Null when there's nothing to
|
||||
* disambiguate: an unpinned event (already in [target]), one pinned to [target]
|
||||
* itself, an all-day event (no zone), or an unparseable pinned zone.
|
||||
*
|
||||
* The form edits a pinned event in its own zone, so this is what lets the UI
|
||||
* show the other side of the pair rather than making the user do the arithmetic.
|
||||
*/
|
||||
fun EventForm.timesIn(target: TimeZone): Pair<LocalDateTime, LocalDateTime>? {
|
||||
if (isAllDay) return null
|
||||
val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return null
|
||||
if (pinned.id == target.id) return null
|
||||
return start.toInstant(pinned).toLocalDateTime(target) to
|
||||
end.toInstant(pinned).toLocalDateTime(target)
|
||||
}
|
||||
|
||||
/**
|
||||
* The optional sections that hold a value in [form] — when editing, these
|
||||
* must be visible regardless of the user's default-fields setting, or the
|
||||
@@ -184,6 +245,7 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
||||
fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
||||
if (location.isNotBlank()) add(EventFormField.Location)
|
||||
if (description.isNotBlank()) add(EventFormField.Description)
|
||||
if (timezone != null) add(EventFormField.Timezone)
|
||||
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
|
||||
if (rrule != null) add(EventFormField.Recurrence)
|
||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import java.text.Normalizer
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.TextStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* One selectable zone, resolved for display at a given instant. [id] is the IANA
|
||||
* id we store in `EVENT_TIMEZONE` and show as the primary label (via [label]);
|
||||
* [shortName] its abbreviation ("CET", "WET", "UTC", or a "GMT+05:30" fallback);
|
||||
* [offsetMinutes] its offset. Both the abbreviation and the offset shift with
|
||||
* DST, so they're only meaningful next to the moment they were resolved for.
|
||||
*
|
||||
* [displayName] is the long localized name ("Central European Time"). It's kept
|
||||
* for search only — matching on it lets someone type "pacific" — and is *not*
|
||||
* displayed: spelled out next to the id it made the field too wide to fit.
|
||||
*/
|
||||
data class TimeZoneOption(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val shortName: String,
|
||||
val offsetMinutes: Int,
|
||||
) {
|
||||
/** The id as the primary label, underscores undone ("America/New York"). */
|
||||
val label: String get() = id.replace('_', ' ')
|
||||
|
||||
/** The trailing segment of the id ("Europe/Berlin" -> "Berlin"), underscores undone. */
|
||||
val city: String get() = id.substringAfterLast('/').replace('_', ' ')
|
||||
|
||||
/** The leading segment ("Europe/Berlin" -> "Europe"); empty for bare ids like "UTC". */
|
||||
val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "")
|
||||
|
||||
/**
|
||||
* The four fields [filterTimeZones] matches on, normalized once here rather
|
||||
* than per query. Normalizing is not cheap — NFD decomposition plus a combining-
|
||||
* mark strip — and a search re-examines every option on every keystroke, so
|
||||
* doing it at construction turns ~2400 normalizations per character into a few
|
||||
* hundred plain `startsWith`/`contains` calls.
|
||||
*
|
||||
* Declared in the class body, so it stays out of `equals`/`hashCode`/`copy`:
|
||||
* it is derived state, and two options with the same id are the same option.
|
||||
*/
|
||||
internal val searchKeys: TimeZoneSearchKeys = TimeZoneSearchKeys(
|
||||
city = city.normalizeForSearch(),
|
||||
id = id.normalizeForSearch(),
|
||||
displayName = displayName.normalizeForSearch(),
|
||||
shortName = shortName.normalizeForSearch(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Pre-normalized match targets for one [TimeZoneOption]. */
|
||||
internal data class TimeZoneSearchKeys(
|
||||
val city: String,
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val shortName: String,
|
||||
)
|
||||
|
||||
private fun optionFor(
|
||||
zone: ZoneId,
|
||||
locale: Locale,
|
||||
at: Instant,
|
||||
regionOf: (String) -> String?,
|
||||
): TimeZoneOption = TimeZoneOption(
|
||||
id = zone.id,
|
||||
displayName = zone.getDisplayName(TextStyle.FULL, locale),
|
||||
shortName = resolveAbbreviation(zone.id, zone.rules.isDaylightSavings(at), locale, regionOf),
|
||||
offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60,
|
||||
)
|
||||
|
||||
/**
|
||||
* The zone abbreviation ("EDT", "CEST"), DST-correct at the resolved instant, or
|
||||
* a "GMT+05:30" form when no name exists.
|
||||
*
|
||||
* Two things make this fiddlier than it looks. First, it goes through
|
||||
* java.util.TimeZone, NOT java.time's "zzz" formatter: on Android the latter has
|
||||
* no short specific-zone names and degrades every zone to a "GMT-4" form (it
|
||||
* agrees with java.util only on desktop, which is why desktop can't catch it).
|
||||
* Second, ICU only surfaces the short name *commonly used in the display
|
||||
* locale's region* — a German-region English phone (en-DE) is shown "CEST" but
|
||||
* not "EDT", and an en-US phone the reverse. So ask in the display *language*
|
||||
* but the *zone's* region ("America/New_York" in en-US, "Europe/Berlin" in
|
||||
* en-DE) via [regionOf]. That covers almost everything; where a region still
|
||||
* yields no name (Athens in en-GR) the plain device locale sometimes does, so
|
||||
* try that next; failing both, the caller shows the offset.
|
||||
*
|
||||
* [regionOf] maps an IANA id to an ISO 3166 region and is injected because it
|
||||
* needs `android.icu`, which this pure-JVM module can't import. The default
|
||||
* (no region) leaves only the device-locale path — which is all a JVM test has
|
||||
* anyway, and enough there since desktop ICU isn't region-gated the same way.
|
||||
*/
|
||||
private fun resolveAbbreviation(
|
||||
id: String,
|
||||
dst: Boolean,
|
||||
locale: Locale,
|
||||
regionOf: (String) -> String?,
|
||||
): String {
|
||||
fun shortIn(loc: Locale): String =
|
||||
java.util.TimeZone.getTimeZone(id).getDisplayName(dst, java.util.TimeZone.SHORT, loc)
|
||||
val regional = regionOf(id)
|
||||
?.takeIf { it.length == 2 }
|
||||
?.let { shortIn(Locale(locale.language, it)) }
|
||||
if (regional != null && !regional.looksLikeOffset()) return regional
|
||||
// Either no region, or the region has no name for this zone: the device
|
||||
// locale is the next-best shot, and the offset the last resort.
|
||||
return shortIn(locale)
|
||||
}
|
||||
|
||||
/** True for the offset-style names ICU returns when a zone has no abbreviation. */
|
||||
private fun String.looksLikeOffset(): Boolean = this == "UTC" || startsWith("GMT")
|
||||
|
||||
/**
|
||||
* Every zone the JVM knows, resolved at [at]. [regionOf] (see
|
||||
* [resolveAbbreviation]) supplies the zone's region for the abbreviation.
|
||||
*
|
||||
* This is ~600 entries, each costing a localized display name plus up to two ICU
|
||||
* short-name lookups, so it is **not** cheap enough for the main thread — build
|
||||
* it off-thread once and filter the result with [filterTimeZones] rather than
|
||||
* rebuilding per keystroke.
|
||||
*
|
||||
* Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are
|
||||
* dropped: they're aliases the tz database keeps for compatibility, they'd
|
||||
* double up the real zones in the list, and none of them is what a user means
|
||||
* when they pick a place.
|
||||
*/
|
||||
fun timeZoneOptions(
|
||||
locale: Locale = Locale.getDefault(),
|
||||
at: Instant = Instant.now(),
|
||||
regionOf: (String) -> String? = { null },
|
||||
): List<TimeZoneOption> = ZoneId.getAvailableZoneIds()
|
||||
.asSequence()
|
||||
.filter { it.contains('/') && !it.startsWith("SystemV/") }
|
||||
.map { optionFor(ZoneId.of(it), locale, at, regionOf) }
|
||||
.sortedWith(compareBy({ it.region }, { it.city }))
|
||||
.toList()
|
||||
|
||||
/**
|
||||
* Resolve a single [id] the same way [timeZoneOptions] would, or null if the tz
|
||||
* database doesn't know it — for labelling one known zone without paying to
|
||||
* build the whole catalogue.
|
||||
*/
|
||||
fun timeZoneOptionOf(
|
||||
id: String,
|
||||
locale: Locale = Locale.getDefault(),
|
||||
at: Instant = Instant.now(),
|
||||
regionOf: (String) -> String? = { null },
|
||||
): TimeZoneOption? {
|
||||
val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null
|
||||
return optionFor(zone, locale, at, regionOf)
|
||||
}
|
||||
|
||||
/**
|
||||
* The short descriptor shown beneath the id, e.g. "CET · GMT+01:00". "UTC" reads
|
||||
* fine alone; any other offset-shaped name is normalised to our own GMT form so
|
||||
* the offset isn't stated twice in ICU's spelling and ours.
|
||||
*/
|
||||
fun zoneDescriptor(option: TimeZoneOption): String {
|
||||
val abbrev = option.shortName
|
||||
return when {
|
||||
abbrev == "UTC" -> "UTC"
|
||||
abbrev.startsWith("GMT") -> formatGmtOffset(option.offsetMinutes)
|
||||
else -> "$abbrev · ${formatGmtOffset(option.offsetMinutes)}"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [options] matching [query], best matches first; a blank query returns the list
|
||||
* unchanged. Matching is accent- and case-insensitive and treats underscores as
|
||||
* spaces, so "sao paulo" finds "America/Sao_Paulo".
|
||||
*
|
||||
* Ranking puts a city that *starts with* the query above one that merely
|
||||
* contains it — typing "col" should reach Colombo before Turks_and_Caicos —
|
||||
* and the id is matched ahead of the localized name so a user who knows the
|
||||
* IANA id gets it first. An exact abbreviation hit ("CEST") ranks just under a
|
||||
* city prefix, so typing an abbreviation surfaces every zone that shows it
|
||||
* (all the CEST zones together); the abbreviation is the one resolved for the
|
||||
* display region, i.e. what the row actually shows.
|
||||
*/
|
||||
fun filterTimeZones(options: List<TimeZoneOption>, query: String): List<TimeZoneOption> {
|
||||
val needle = query.normalizeForSearch()
|
||||
if (needle.isEmpty()) return options
|
||||
return options
|
||||
.mapNotNull { option ->
|
||||
val keys = option.searchKeys
|
||||
val rank = when {
|
||||
keys.city.startsWith(needle) -> 0
|
||||
keys.shortName == needle -> 1
|
||||
keys.displayName.startsWith(needle) -> 2
|
||||
keys.shortName.startsWith(needle) -> 3
|
||||
keys.city.contains(needle) -> 4
|
||||
keys.id.contains(needle) -> 5
|
||||
keys.displayName.contains(needle) -> 6
|
||||
else -> return@mapNotNull null
|
||||
}
|
||||
rank to option
|
||||
}
|
||||
.sortedWith(compareBy({ it.first }, { it.second.city }))
|
||||
.map { it.second }
|
||||
}
|
||||
|
||||
/** Unicode combining marks — what NFD decomposition leaves an accent as. */
|
||||
private val COMBINING_MARKS = Regex("\\p{Mn}+")
|
||||
|
||||
/**
|
||||
* Lowercased, accent-stripped, underscores and slashes flattened to spaces, so
|
||||
* a query types the way a place is spoken rather than the way the tz database
|
||||
* spells it.
|
||||
*/
|
||||
private fun String.normalizeForSearch(): String =
|
||||
Normalizer.normalize(this, Normalizer.Form.NFD)
|
||||
.replace(COMBINING_MARKS, "")
|
||||
.replace('_', ' ')
|
||||
.replace('/', ' ')
|
||||
.lowercase(Locale.ROOT)
|
||||
.trim()
|
||||
|
||||
/** "GMT+02:00" / "GMT-05:30" / "GMT" — the offset as shown next to a zone. */
|
||||
fun formatGmtOffset(offsetMinutes: Int): String {
|
||||
if (offsetMinutes == 0) return "GMT"
|
||||
val sign = if (offsetMinutes < 0) '-' else '+'
|
||||
val abs = kotlin.math.abs(offsetMinutes)
|
||||
return "GMT%c%02d:%02d".format(sign, abs / 60, abs % 60)
|
||||
}
|
||||
@@ -88,6 +88,8 @@ fun CalendarHost(
|
||||
// sensible non-empty initial values, so they're ready before the first frame.
|
||||
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
|
||||
val drawerViewOrder = viewModel.drawerViewOrder.collectAsStateWithLifecycle().value
|
||||
// Whether the jump-to-today control sits in each view's top bar or the FAB (#60).
|
||||
val todayInToolbar = viewModel.todayButtonInToolbar.collectAsStateWithLifecycle().value
|
||||
|
||||
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
||||
mutableStateOf(listOf(defaultView))
|
||||
@@ -318,6 +320,7 @@ fun CalendarHost(
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
CalendarView.Day -> DayScreen(
|
||||
selectedView = currentView,
|
||||
@@ -329,16 +332,19 @@ fun CalendarHost(
|
||||
initialDateIso = pendingDayIso,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
CalendarView.Month -> MonthScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
CalendarView.Agenda -> AgendaScreen(
|
||||
selectedView = currentView,
|
||||
@@ -350,6 +356,7 @@ fun CalendarHost(
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,4 +45,12 @@ class CalendarHostViewModel @Inject constructor(
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = IMPLEMENTED_VIEWS,
|
||||
)
|
||||
|
||||
/** Whether each view's jump-to-today control lives in the top bar, not the FAB (#60). */
|
||||
val todayButtonInToolbar: StateFlow<Boolean> = prefs.todayButtonInToolbar
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import java.time.YearMonth
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Coffee
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
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 de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import java.util.Locale
|
||||
|
||||
// The agenda's row vocabulary, split out of AgendaScreen so the month view's
|
||||
// split style can list a day with exactly the same visual language instead of
|
||||
// growing a parallel set of event rows.
|
||||
|
||||
@Composable
|
||||
internal fun AgendaDayHeader(
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onOpenDay(date) },
|
||||
) {
|
||||
Text(
|
||||
text = agendaDayLabel(date, today),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = if (date == today) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A card standing in for a day with no events — the same coffee-cup motif as the
|
||||
* agenda's full-screen empty state, boxed into a card so the day keeps a visible slot
|
||||
* rather than a bare header. Used for an anchored, event-less today (#35) and for
|
||||
* an empty selected day in the month view's split style.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AgendaEmptyDayRow(text: String, onClick: () -> Unit) {
|
||||
Card(
|
||||
// Match a single event row's resting corner radius (floret groupedShape).
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 18.dp, horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Coffee,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun AgendaEventRow(
|
||||
event: EventInstance,
|
||||
day: LocalDate,
|
||||
zone: TimeZone,
|
||||
position: Position,
|
||||
dimmed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
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),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
leading = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 6.dp, height = 36.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(eventFill(event.color, dark, soften)),
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */
|
||||
@Composable
|
||||
internal fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
|
||||
val relative = when (date) {
|
||||
today -> stringResource(R.string.agenda_header_today)
|
||||
today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow)
|
||||
else -> null
|
||||
}
|
||||
val formatted = formatAgendaDate(date)
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
/**
|
||||
* Time line under the title: "09:00 – 10:00 · Location", "All day", etc.
|
||||
*
|
||||
* A multi-day event shows only the part relevant to [day], spelled out so each
|
||||
* day reads on its own: its first day names the start ("Starts 14:00"), its last
|
||||
* day the end ("Ends 10:00"), and any whole day in between reads as "All day".
|
||||
* An all-day multi-day event is simply "All day" on every day it covers.
|
||||
*/
|
||||
@Composable
|
||||
internal fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZone): String {
|
||||
val is24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
|
||||
val time = when (val label = agendaTimeLabel(event, day, zone)) {
|
||||
AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
|
||||
is AgendaTimeLabel.Starts -> stringResource(
|
||||
R.string.agenda_span_starts,
|
||||
formatTime(label.start, zone, is24Hour, locale),
|
||||
)
|
||||
is AgendaTimeLabel.Ends -> stringResource(
|
||||
R.string.agenda_span_ends,
|
||||
formatTime(label.end, zone, is24Hour, locale),
|
||||
)
|
||||
is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " +
|
||||
formatTime(label.end, zone, is24Hour, locale)
|
||||
}
|
||||
|
||||
val location = event.location?.takeIf { it.isNotBlank() }
|
||||
return if (location != null) "$time · $location" else time
|
||||
}
|
||||
|
||||
private fun formatTime(
|
||||
instant: Instant,
|
||||
zone: TimeZone,
|
||||
is24Hour: Boolean,
|
||||
locale: Locale,
|
||||
): String {
|
||||
val t = instant.toLocalDateTime(zone).time
|
||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
|
||||
}
|
||||
|
||||
private fun formatAgendaDate(date: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
|
||||
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
|
||||
return localizedDateFormatter(locale, "EEEdMMMy").format(java)
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -17,13 +14,11 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Coffee
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -32,7 +27,6 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
@@ -46,8 +40,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -59,36 +51,27 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
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.localizedDateFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import java.util.Locale
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
// No file-level zone constant here on purpose: it would be fixed for the process
|
||||
// lifetime and drift from the zone AgendaViewModel groups in after a device
|
||||
// time-zone change. The zone travels on AgendaUiState.Success instead.
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -102,6 +85,7 @@ fun AgendaScreen(
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
todayInToolbar: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AgendaViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -151,12 +135,14 @@ fun AgendaScreen(
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = viewModel::goToToday,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
CalendarFabColumn(
|
||||
todayVisible = !isOnToday,
|
||||
todayVisible = !isOnToday && !todayInToolbar,
|
||||
todayText = stringResource(R.string.agenda_today_action),
|
||||
onToday = viewModel::goToToday,
|
||||
onCreate = { onCreateEvent(anchor, null) },
|
||||
@@ -328,6 +314,7 @@ private fun AgendaContent(
|
||||
AgendaList(
|
||||
days = days,
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||
now = now,
|
||||
onEventClick = onEventClick,
|
||||
@@ -344,6 +331,7 @@ private fun AgendaContent(
|
||||
private fun AgendaList(
|
||||
days: List<AgendaDay>,
|
||||
today: LocalDate,
|
||||
zone: TimeZone,
|
||||
dimPast: Boolean,
|
||||
now: Instant,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
@@ -362,15 +350,23 @@ private fun AgendaList(
|
||||
if (day.events.isEmpty()) {
|
||||
// An anchored, event-less today (#35) — "nothing left today".
|
||||
item(key = "placeholder-${day.date}") {
|
||||
AgendaEmptyDayRow(onClick = { onOpenDay(day.date) })
|
||||
AgendaEmptyDayRow(
|
||||
text = stringResource(R.string.agenda_no_more_today),
|
||||
onClick = { onOpenDay(day.date) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
itemsIndexed(
|
||||
items = day.events,
|
||||
key = { _, event -> event.instanceId },
|
||||
// Scope the key by day: a multi-day event appears under every
|
||||
// day it spans, so its instanceId alone is not unique across
|
||||
// the list (LazyColumn requires unique keys).
|
||||
key = { _, event -> "${day.date}-${event.instanceId}" },
|
||||
) { index, event ->
|
||||
AgendaEventRow(
|
||||
event = event,
|
||||
day = day.date,
|
||||
zone = zone,
|
||||
position = positionOf(index, day.events.size),
|
||||
dimmed = dimPast && event.hasEnded(now),
|
||||
modifier = animateItemMotion(),
|
||||
@@ -383,98 +379,6 @@ private fun AgendaList(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaDayHeader(
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onOpenDay(date) },
|
||||
) {
|
||||
Text(
|
||||
text = agendaDayLabel(date, today),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = if (date == today) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A card under an anchored, event-less today (#35) — the same coffee-cup motif
|
||||
* as the full-screen [AgendaEmpty] state, boxed into a card so today keeps a
|
||||
* visible slot when nothing is left rather than a bare header.
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaEmptyDayRow(onClick: () -> Unit) {
|
||||
Card(
|
||||
// Match a single event row's resting corner radius (floret groupedShape).
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 18.dp, horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Coffee,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.agenda_no_more_today),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaEventRow(
|
||||
event: EventInstance,
|
||||
position: Position,
|
||||
dimmed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
GroupedRow(
|
||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = title,
|
||||
summary = agendaTimeSummary(event),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
leading = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 6.dp, height = 36.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(eventFill(event.color, dark, soften)),
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
@@ -504,6 +408,8 @@ private fun AgendaTopBar(
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
@@ -522,6 +428,7 @@ private fun AgendaTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TodayAction(show = showTodayButton, onToday = onToday)
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
@@ -541,42 +448,3 @@ private fun AgendaTopBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
|
||||
/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */
|
||||
@Composable
|
||||
private fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
|
||||
val relative = when (date) {
|
||||
today -> stringResource(R.string.agenda_header_today)
|
||||
today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow)
|
||||
else -> null
|
||||
}
|
||||
val formatted = formatAgendaDate(date)
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
/** Time line under the title: "09:00 – 10:00 · Location", "All day", etc. */
|
||||
@Composable
|
||||
private fun agendaTimeSummary(event: EventInstance): String {
|
||||
val time = if (event.isAllDay) {
|
||||
stringResource(R.string.event_detail_all_day)
|
||||
} else {
|
||||
val is24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
"${formatTime(event.start, is24Hour, locale)} – ${formatTime(event.end, is24Hour, locale)}"
|
||||
}
|
||||
val location = event.location?.takeIf { it.isNotBlank() }
|
||||
return if (location != null) "$time · $location" else time
|
||||
}
|
||||
|
||||
private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): String {
|
||||
val t = instant.toLocalDateTime(zone).time
|
||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
|
||||
}
|
||||
|
||||
private fun formatAgendaDate(date: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
|
||||
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
|
||||
return localizedDateFormatter(locale, "EEEdMMMy").format(java)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,70 @@ package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The zone the event's calendar dates live in. Timed events are resolved in the
|
||||
* device [zone]; all-day events live at UTC midnights with an exclusive end, so
|
||||
* resolving them anywhere but UTC shifts the boundaries — east of UTC that leaks
|
||||
* a one-day event onto its next day. Matches the Week view and detail card.
|
||||
*/
|
||||
private fun EventInstance.dateZone(zone: TimeZone): TimeZone =
|
||||
if (isAllDay) TimeZone.UTC else zone
|
||||
|
||||
/** The first calendar day this event occupies. */
|
||||
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
|
||||
start.toLocalDateTime(dateZone(zone)).date
|
||||
|
||||
/**
|
||||
* The last calendar day this event actually occupies. An event ending exactly at
|
||||
* midnight (all-day events end at the exclusive next-midnight) does not reach
|
||||
* into that boundary day, so resolve the instant just before [end].
|
||||
*/
|
||||
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
|
||||
val lastInstant = if (end > start) end - 1.milliseconds else start
|
||||
return lastInstant.toLocalDateTime(dateZone(zone)).date
|
||||
}
|
||||
|
||||
/** Whether this event occupies more than one calendar day in [zone]. */
|
||||
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
|
||||
spanFirstDay(zone) != spanLastDay(zone)
|
||||
|
||||
/**
|
||||
* What an agenda row's time line should convey for an event on a given day —
|
||||
* the part of a multi-day span that [day] falls in. Pure and shared so the
|
||||
* agenda screen and the agenda widget label multi-day events identically; each
|
||||
* surface only formats the instants into its own locale/24h string.
|
||||
*/
|
||||
sealed interface AgendaTimeLabel {
|
||||
/** An all-day event, or a whole in-between day of a multi-day span. */
|
||||
data object AllDay : AgendaTimeLabel
|
||||
/** The first day of a multi-day timed event: when it begins. */
|
||||
data class Starts(val start: Instant) : AgendaTimeLabel
|
||||
/** The last day of a multi-day timed event: when it ends. */
|
||||
data class Ends(val end: Instant) : AgendaTimeLabel
|
||||
/** A single-day timed event: its start–end range. */
|
||||
data class Range(val start: Instant, val end: Instant) : AgendaTimeLabel
|
||||
}
|
||||
|
||||
/** The [AgendaTimeLabel] for [event] as it appears on [day], resolved in [zone]. */
|
||||
fun agendaTimeLabel(event: EventInstance, day: LocalDate, zone: TimeZone): AgendaTimeLabel {
|
||||
if (event.isAllDay) return AgendaTimeLabel.AllDay
|
||||
val firstDay = event.spanFirstDay(zone)
|
||||
val lastDay = event.spanLastDay(zone)
|
||||
return when {
|
||||
firstDay == lastDay -> AgendaTimeLabel.Range(event.start, event.end)
|
||||
day <= firstDay -> AgendaTimeLabel.Starts(event.start)
|
||||
day >= lastDay -> AgendaTimeLabel.Ends(event.end)
|
||||
else -> AgendaTimeLabel.AllDay // a full in-between day
|
||||
}
|
||||
}
|
||||
|
||||
/** One calendar day with at least one event, for the agenda list. */
|
||||
data class AgendaDay(
|
||||
@@ -15,22 +76,41 @@ data class AgendaDay(
|
||||
|
||||
/**
|
||||
* Group flat [instances] into forward-looking [AgendaDay]s (only days that
|
||||
* actually carry events). An event that began before [anchor] (ongoing or
|
||||
* multi-day) is clamped to the anchor day so it still surfaces on top. Within a
|
||||
* day, all-day events sort first, then ascending by start time, then title.
|
||||
* actually carry events). A multi-day event surfaces on *every* day it spans,
|
||||
* not just its first — clamped to [[anchor], [windowEnd]] so an event that began
|
||||
* before the window (ongoing) still lists from the anchor day, and one running
|
||||
* past the window stops at the last visible day. Within a day, all-day events
|
||||
* sort first, then ascending by start time, then title.
|
||||
*
|
||||
* Shared by the Agenda screen and the agenda home-screen widget so both group
|
||||
* and order identically.
|
||||
*/
|
||||
fun groupAgendaDays(
|
||||
anchor: LocalDate,
|
||||
windowEnd: LocalDate,
|
||||
instances: List<EventInstance>,
|
||||
zone: TimeZone,
|
||||
): List<AgendaDay> =
|
||||
instances
|
||||
.groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) }
|
||||
.toSortedMap()
|
||||
.map { (date, dayEvents) ->
|
||||
): List<AgendaDay> {
|
||||
val byDay = sortedMapOf<LocalDate, MutableList<EventInstance>>()
|
||||
for (instance in instances) {
|
||||
val firstDay = instance.spanFirstDay(zone).coerceAtLeast(anchor)
|
||||
val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd)
|
||||
// Skip instances that don't actually occupy any day in [[anchor], [windowEnd]].
|
||||
// The provider returns an event whenever its instant span overlaps the query
|
||||
// window, but all-day events live at UTC midnights with an exclusive end: east
|
||||
// of UTC that end dips just past local midnight, so *yesterday's* all-day event
|
||||
// overlaps today's window start and comes back even though its true last day
|
||||
// (resolved in UTC) is before the anchor. Clamping it up to the anchor would
|
||||
// surface it under "today" (issue #65); drop it instead. The symmetric case —
|
||||
// a next-day all-day event overlapping the window's last instant — drops too.
|
||||
if (lastDay < firstDay) continue
|
||||
var day = firstDay
|
||||
while (day <= lastDay) {
|
||||
byDay.getOrPut(day) { mutableListOf() }.add(instance)
|
||||
day = day.plus(1, DateTimeUnit.DAY)
|
||||
}
|
||||
}
|
||||
return byDay.map { (date, dayEvents) ->
|
||||
AgendaDay(
|
||||
date = date,
|
||||
events = dayEvents.sortedWith(
|
||||
@@ -40,6 +120,7 @@ fun groupAgendaDays(
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure [today] surfaces as the first agenda day even when it carries no
|
||||
@@ -79,5 +160,12 @@ sealed interface AgendaUiState {
|
||||
val rangeEnd: LocalDate,
|
||||
/** Whether to show the top range bar — header + switcher (toggle, on by default). */
|
||||
val showRangeBar: Boolean,
|
||||
/**
|
||||
* The zone [days] were grouped in. Carried in the state rather than
|
||||
* re-read by the screen so labelling and grouping cannot disagree: an
|
||||
* event's "Starts …/Ends …/All day" line is only correct relative to the
|
||||
* same zone that decided which day it was filed under.
|
||||
*/
|
||||
val zone: TimeZone,
|
||||
) : AgendaUiState
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
@@ -20,13 +20,11 @@ import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import java.util.Locale
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
@@ -51,8 +49,7 @@ class AgendaViewModel @Inject constructor(
|
||||
) { range, showBar -> AgendaSettings(range, showBar) }
|
||||
|
||||
// First day of the week, for the calendar-aligned "this week" range.
|
||||
private val weekStartDay = settingsPrefs.weekStart
|
||||
.map { it.resolveFirstDay(Locale.getDefault()) }
|
||||
private val weekStartDay = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||
|
||||
/**
|
||||
* How to treat events that already ended today (show / dim / hide). A display
|
||||
@@ -159,11 +156,11 @@ class AgendaViewModel @Inject constructor(
|
||||
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
val anchor = params.anchor
|
||||
val days = groupAgendaDays(anchor, instances, zone)
|
||||
val rangeEnd = anchor.plus(
|
||||
params.range.dayCount(anchor, params.weekStart) - 1,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
val days = groupAgendaDays(anchor, rangeEnd, instances, zone)
|
||||
return AgendaUiState.Success(
|
||||
anchor = anchor,
|
||||
today = todayDate,
|
||||
@@ -172,6 +169,7 @@ class AgendaViewModel @Inject constructor(
|
||||
rangeIsOverride = params.rangeIsOverride,
|
||||
rangeEnd = rangeEnd,
|
||||
showRangeBar = params.showRangeBar,
|
||||
zone = zone,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -33,6 +32,7 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
|
||||
/**
|
||||
* The app's single "which calendar" selection list, shared by the event editor
|
||||
@@ -99,13 +99,7 @@ private fun CalendarPickerGroup(
|
||||
selected = isSelected,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = if (isSelected) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Drag distance that turns a calendar page, shared by the month, week and day
|
||||
* views so all three answer a swipe at the same point.
|
||||
*
|
||||
* It was 6dp once, which is inside the distance a tap wanders: brushing the grid
|
||||
* changed the month, and a page that turns on an unintended gesture reads as the
|
||||
* animation misfiring rather than as the gesture being over-eager.
|
||||
*/
|
||||
val CALENDAR_SWIPE_THRESHOLD = 24.dp
|
||||
|
||||
/**
|
||||
* The whole-page horizontal swipe: one page per gesture, committed **the moment
|
||||
* the drag clears [CALENDAR_SWIPE_THRESHOLD]** rather than when the finger lifts.
|
||||
*
|
||||
* Waiting for the lift meant the page sat still under a finger that had already
|
||||
* travelled far enough to ask for it, and the answer only arrived once you let
|
||||
* go — which reads as the view being slow rather than as a deliberate commit.
|
||||
* Firing on the threshold is what makes the gesture feel like it is being
|
||||
* followed. The trade is that a drag can no longer be taken back by dragging the
|
||||
* other way; in practice, once you have moved 24dp deliberately you meant it, and
|
||||
* the page you land on is one swipe back.
|
||||
*
|
||||
* Deliberately **horizontal-only**. The week and day timelines scroll vertically
|
||||
* underneath this, and a two-dimensional detector here would claim those drags
|
||||
* before the inner scroll ever saw them. As it is, a horizontal drag crosses this
|
||||
* detector's slop while a vertical one is consumed below, and the two coexist.
|
||||
* (The month view's split style needs a vertical axis as well, so it keeps its
|
||||
* own axis-locking detector rather than using this.)
|
||||
*/
|
||||
@Composable
|
||||
fun rememberCalendarPageSwipe(
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
): Modifier {
|
||||
val threshold = with(LocalDensity.current) { CALENDAR_SWIPE_THRESHOLD.toPx() }
|
||||
return Modifier.pointerInput(onSwipeNext, onSwipePrev) {
|
||||
var accum = 0f
|
||||
// One page per gesture: without this a long drag would keep re-firing
|
||||
// every time the accumulator crossed the threshold again.
|
||||
var fired = false
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = {
|
||||
accum = 0f
|
||||
fired = false
|
||||
},
|
||||
onDragEnd = {
|
||||
accum = 0f
|
||||
fired = false
|
||||
},
|
||||
onDragCancel = {
|
||||
accum = 0f
|
||||
fired = false
|
||||
},
|
||||
onHorizontalDrag = { _, drag ->
|
||||
accum += drag
|
||||
if (!fired) {
|
||||
val commit = when {
|
||||
accum < -threshold -> onSwipeNext
|
||||
accum > threshold -> onSwipePrev
|
||||
else -> null
|
||||
}
|
||||
if (commit != null) {
|
||||
fired = true
|
||||
commit()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Formats a calendar title (top bar or widget header): [skeleton]'s fields laid
|
||||
* out in [locale]'s own order, with the year shown only when [date] falls outside
|
||||
* [currentYear].
|
||||
*
|
||||
* Pass [skeleton] *without* a year field — "LLLL" for a month, "EEEdMMM" for a
|
||||
* day. A skeleton is a field list, so wanting the year is just asking for one
|
||||
* more field; the locale still decides where it lands ("July 2026" vs "2026年7月").
|
||||
*
|
||||
* Dropping the year in the current year is the one bit of policy here: the title
|
||||
* sits directly above a grid that already says which year it is, and the year's
|
||||
* *absence* is itself the signal that you're in the current one — it appears the
|
||||
* moment you page out of it, which is when it starts carrying information.
|
||||
*
|
||||
* [forceYear] overrides that for titles whose [date] does not tell the whole
|
||||
* story — a week view's title names only the month its *first* day falls in, so
|
||||
* a week straddling New Year must still show the year even though [date] is in
|
||||
* [currentYear].
|
||||
*/
|
||||
fun formatCalendarTitle(
|
||||
date: java.time.LocalDate,
|
||||
locale: Locale,
|
||||
currentYear: Int,
|
||||
skeleton: String,
|
||||
forceYear: Boolean = false,
|
||||
): String {
|
||||
val fields = if (date.year == currentYear && !forceYear) skeleton else skeleton + "y"
|
||||
return localizedDateFormatter(locale, fields).format(date)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -23,9 +24,13 @@ import androidx.compose.ui.unit.IntOffset
|
||||
*/
|
||||
|
||||
/**
|
||||
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
|
||||
* spring-physics spec from the active motion scheme — snappy with a subtle
|
||||
* springy settle, rather than a fixed easing curve.
|
||||
* The M3 Expressive spatial spring used for the month/week/day slide: the
|
||||
* *default* spring-physics spec from the active motion scheme, rather than a
|
||||
* fixed easing curve.
|
||||
*
|
||||
* Default rather than fast: `fastSpatialSpec` is tuned for small, incidental
|
||||
* movement, and driving a whole calendar page with it made the settle read as a
|
||||
* twitch instead of a glide.
|
||||
*
|
||||
* Read it in a composable scope (this helper) so it can be captured by the
|
||||
* non-composable `AnimatedContent` transitionSpec lambda.
|
||||
@@ -33,27 +38,34 @@ import androidx.compose.ui.unit.IntOffset
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
MaterialTheme.motionScheme.defaultSpatialSpec()
|
||||
|
||||
/**
|
||||
* The fast effects spec from the active motion scheme, for opacity (fade)
|
||||
* transitions. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
||||
* for use in non-composable transition lambdas and as the reduced-motion fallback.
|
||||
* The effects spec from the active motion scheme, for the opacity half of the
|
||||
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
||||
* for use in non-composable transition lambdas, and reused on its own as the
|
||||
* reduced-motion fallback.
|
||||
*
|
||||
* Opacity gets its own spec on purpose: M3 Expressive springs *position* and
|
||||
* eases *opacity*, and a fade that bounced with the slide would shimmer.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
|
||||
MaterialTheme.motionScheme.fastEffectsSpec()
|
||||
MaterialTheme.motionScheme.defaultEffectsSpec()
|
||||
|
||||
/**
|
||||
* Horizontal slide for navigating between adjacent months/weeks/days.
|
||||
* Navigating between adjacent months/weeks/days, as M3's shared-axis X: the
|
||||
* outgoing page slides and fades one way while the incoming one arrives from the
|
||||
* other, position on a spring and opacity on an easing curve.
|
||||
*
|
||||
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
||||
* (e.g. "today"); a jump reuses the forward direction.
|
||||
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
||||
* @param fadeSpec effects spec for the reduced-motion fade, typically
|
||||
* @param fadeSpec effects spec for the opacity half, and for the whole
|
||||
* transition under reduced motion; typically
|
||||
* [rememberCalendarFadeSpec].
|
||||
* @param reduceMotion when true, swap the directional slide for a plain cross-fade.
|
||||
* @param reduceMotion when true, drop the movement and cross-fade alone.
|
||||
*/
|
||||
fun calendarSlideTransition(
|
||||
slideDir: Int,
|
||||
@@ -65,6 +77,26 @@ fun calendarSlideTransition(
|
||||
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
||||
}
|
||||
val dir = if (slideDir == 0) 1 else slideDir
|
||||
return slideInHorizontally(spec) { w -> dir * w }
|
||||
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
|
||||
return ContentTransform(
|
||||
targetContentEnter =
|
||||
slideInHorizontally(spec) { w -> dir * w / SLIDE_TRAVEL_DIVISOR } + fadeIn(fadeSpec),
|
||||
initialContentExit =
|
||||
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
|
||||
// AnimatedContent clips to the animating container by default, which
|
||||
// shears the pages against the viewport edge as they pass. There is no
|
||||
// size change here to contain — both pages are the same grid.
|
||||
sizeTransform = SizeTransform(clip = false),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* How far a page travels, as a fraction of the container width.
|
||||
*
|
||||
* A full width was the obvious reading of "paging", but the two pages are
|
||||
* stacked and both opaque, so a full-width slide showed one grid racing across
|
||||
* another — the movement carried the whole transition and had a long way to go.
|
||||
* Under M3's shared-axis pattern the offset only has to *hint* the direction
|
||||
* while the cross-fade does the swapping, so a fifth of the width is plenty and
|
||||
* leaves nothing skating past.
|
||||
*/
|
||||
private const val SLIDE_TRAVEL_DIVISOR = 5
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material.icons.filled.Place
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Repeat
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
@@ -24,6 +25,7 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
|
||||
EventFormField.Location -> R.string.event_detail_location
|
||||
EventFormField.Description -> R.string.event_detail_description
|
||||
EventFormField.Timezone -> R.string.event_detail_timezone
|
||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||
EventFormField.Availability -> R.string.event_edit_availability
|
||||
@@ -35,6 +37,7 @@ fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
|
||||
fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) {
|
||||
EventFormField.Location -> Icons.Default.Place
|
||||
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
||||
EventFormField.Timezone -> Icons.Default.Public
|
||||
EventFormField.Reminders -> Icons.Default.Notifications
|
||||
EventFormField.Recurrence -> Icons.Default.Repeat
|
||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Current display [Locale], read observably from [LocalConfiguration] so the UI
|
||||
* recomposes after a locale change (lint: NonObservableLocale). Used for
|
||||
* weekday/month name formatting.
|
||||
*/
|
||||
@Composable
|
||||
fun currentLocale(): Locale {
|
||||
val configuration = LocalConfiguration.current
|
||||
return remember(configuration) {
|
||||
ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [DateTimeFormatter] for [skeleton]'s fields laid out in [locale]'s own order
|
||||
* (via Android's best-pattern matching), so dates read naturally per locale
|
||||
* instead of a hardcoded day-month-year order. [skeleton] lists the wanted
|
||||
* fields, e.g. "dMMMy" (day, abbreviated month, year) or "EEEdMMM" (weekday too).
|
||||
*/
|
||||
fun localizedDateFormatter(locale: Locale, skeleton: String): DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern(
|
||||
android.text.format.DateFormat.getBestDateTimePattern(locale, skeleton),
|
||||
locale,
|
||||
)
|
||||
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -26,6 +25,7 @@ import de.jeanlucmakiola.floret.components.CustomAmountEditor
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
@@ -198,7 +198,7 @@ private fun CustomReminderEditor(
|
||||
|
||||
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
||||
@Composable
|
||||
private fun PickerDescription(text: String) {
|
||||
internal fun PickerDescription(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -207,14 +207,6 @@ private fun PickerDescription(text: String) {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectedCheck() {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
|
||||
|
||||
@@ -9,6 +9,10 @@ import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
@@ -111,16 +115,36 @@ private fun rruleDayName(token: String, locale: Locale): String? {
|
||||
|
||||
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
|
||||
private fun parseUntilDate(raw: String, locale: Locale): String? {
|
||||
val digits = raw.takeWhile { it.isDigit() }
|
||||
if (digits.length < 8) return null
|
||||
return try {
|
||||
val date = java.time.LocalDate.of(
|
||||
val date = untilLocalDate(raw) ?: return null
|
||||
return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
||||
}
|
||||
|
||||
private val UNTIL_UTC_FORMAT: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'", Locale.ROOT)
|
||||
|
||||
/**
|
||||
* The calendar day an RRULE UNTIL value denotes, *in the device's zone*.
|
||||
*
|
||||
* The UTC form ("20261231T225959Z") must be converted back before its date is
|
||||
* read: `SimpleRecurrence.toRRule` writes the end of the chosen **local** day
|
||||
* expressed in UTC, so for zones behind UTC that instant already falls on the
|
||||
* following UTC date. Taking the leading digits straight off would then show
|
||||
* the day after the one the user picked. Date-only and floating forms are
|
||||
* already local and pass through unchanged.
|
||||
*/
|
||||
internal fun untilLocalDate(raw: String, zone: ZoneId = ZoneId.systemDefault()): LocalDate? {
|
||||
val value = raw.trim()
|
||||
return runCatching {
|
||||
LocalDateTime.parse(value, UNTIL_UTC_FORMAT)
|
||||
.atOffset(ZoneOffset.UTC)
|
||||
.atZoneSameInstant(zone)
|
||||
.toLocalDate()
|
||||
}.recoverCatching {
|
||||
val digits = value.takeWhile { it.isDigit() }
|
||||
LocalDate.of(
|
||||
digits.substring(0, 4).toInt(),
|
||||
digits.substring(4, 6).toInt(),
|
||||
digits.substring(6, 8).toInt(),
|
||||
)
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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 de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
||||
import de.jeanlucmakiola.calendula.domain.filterTimeZones
|
||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
|
||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Full-screen zone picker: a query field over every zone the JVM knows, with the
|
||||
* device zone and recently-picked ones pinned on top so the common case needs no
|
||||
* typing. [selected] is the pinned zone id, or null when the event follows the
|
||||
* device — picking the device row reports null back, which is what keeps an
|
||||
* unpinned event unpinned rather than freezing it to today's zone.
|
||||
*
|
||||
* Unlike the kit's [de.jeanlucmakiola.floret.components.OptionPicker], this
|
||||
* drives its own [LazyColumn] (hence `scrollable = false`): ~600 options is far
|
||||
* past what an eagerly composed column can carry.
|
||||
*/
|
||||
@Composable
|
||||
fun TimeZonePickerDialog(
|
||||
selected: String?,
|
||||
deviceZoneId: String,
|
||||
recents: List<String>,
|
||||
onSelect: (String?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
val locale = currentLocale()
|
||||
// ~600 zones, each resolving a localized name and up to two ICU short names:
|
||||
// far too much to run inside composition, so build it on a worker and let the
|
||||
// list fill in a frame later. Filtering the built catalogue is cheap (its
|
||||
// search keys are pre-normalized), so that stays here.
|
||||
val allZones by produceState(initialValue = emptyList<TimeZoneOption>(), locale) {
|
||||
value = withContext(Dispatchers.Default) {
|
||||
timeZoneOptions(locale, regionOf = ::icuTimeZoneRegion)
|
||||
}
|
||||
}
|
||||
val matches = remember(allZones, query) { filterTimeZones(allZones, query) }
|
||||
val recentZones = remember(allZones, recents) {
|
||||
recents.mapNotNull { id -> allZones.firstOrNull { it.id == id } }
|
||||
}
|
||||
// Resolved on its own rather than looked up in the catalogue: it's one zone,
|
||||
// so it costs nothing, and the device row can then render complete on the
|
||||
// first frame instead of showing a bare id until the catalogue lands.
|
||||
val deviceSummary = remember(deviceZoneId, locale) {
|
||||
timeZoneOptionOf(deviceZoneId, locale, regionOf = ::icuTimeZoneRegion)
|
||||
?.let { zoneDescriptor(it) }
|
||||
?: deviceZoneId
|
||||
}
|
||||
val searching = query.isNotBlank()
|
||||
|
||||
fun choose(zoneId: String?) {
|
||||
onSelect(zoneId)
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.event_detail_timezone),
|
||||
onDismiss = onDismiss,
|
||||
scrollable = false,
|
||||
) {
|
||||
SearchField(
|
||||
query = query,
|
||||
onQueryChange = { query = it },
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
|
||||
if (!searching) {
|
||||
// The device row is the "unpinned" choice, so it reads as a mode
|
||||
// rather than as a zone among zones — it stays out of the big
|
||||
// list and never carries an offset.
|
||||
item(key = "device") {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.event_edit_timezone_device),
|
||||
summary = deviceSummary,
|
||||
position = Position.Alone,
|
||||
selected = selected == null,
|
||||
leading = { Icon(Icons.Default.Public, contentDescription = null) },
|
||||
trailing = if (selected == null) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { choose(null) },
|
||||
)
|
||||
}
|
||||
if (recentZones.isNotEmpty()) {
|
||||
item(key = "recent_header") {
|
||||
SectionHeader(stringResource(R.string.event_edit_timezone_recent))
|
||||
}
|
||||
itemsIndexed(
|
||||
items = recentZones,
|
||||
key = { _, zone -> "recent_${zone.id}" },
|
||||
) { index, zone ->
|
||||
ZoneRow(
|
||||
zone = zone,
|
||||
position = positionOf(index, recentZones.size),
|
||||
selected = zone.id == selected,
|
||||
onClick = { choose(zone.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
item(key = "all_header") {
|
||||
SectionHeader(stringResource(R.string.event_edit_timezone_all))
|
||||
}
|
||||
}
|
||||
|
||||
// allZones.isNotEmpty() gates this: until the catalogue lands there is
|
||||
// simply nothing to match yet, and claiming "no time zone matches"
|
||||
// for that frame would be wrong.
|
||||
if (searching && matches.isEmpty() && allZones.isNotEmpty()) {
|
||||
item(key = "empty") {
|
||||
Text(
|
||||
text = stringResource(R.string.event_edit_timezone_none, query),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp, vertical = 48.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
items = matches,
|
||||
key = { _, zone -> "zone_${zone.id}" },
|
||||
) { index, zone ->
|
||||
ZoneRow(
|
||||
zone = zone,
|
||||
position = positionOf(index, matches.size),
|
||||
selected = zone.id == selected,
|
||||
onClick = { choose(zone.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The query box: the family's borderless [InlineTextField] over a tonal surface,
|
||||
* so it reads like the rest of the app's inputs rather than a boxed Material
|
||||
* field. The clear button rides inside the surface (unlike the search screen,
|
||||
* which has a top bar to put it in) because the picker's bar is the title.
|
||||
*/
|
||||
@Composable
|
||||
private fun SearchField(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
// surfaceContainerHighest — the picker sits on surfaceContainerHigh, so
|
||||
// anything lower vanishes into it.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
InlineTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = stringResource(R.string.event_edit_timezone_search),
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Search,
|
||||
onImeAction = { keyboard?.hide() },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||
)
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.search_clear),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A small primary-coloured group label, matching the settings screens. */
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun ZoneRow(
|
||||
zone: TimeZoneOption,
|
||||
position: Position,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = zone.label,
|
||||
summary = zoneDescriptor(zone),
|
||||
position = position,
|
||||
selected = selected,
|
||||
trailing = if (selected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.icu.util.TimeZone as IcuTimeZone
|
||||
|
||||
/**
|
||||
* The ISO 3166 region an IANA zone belongs to ("America/New_York" -> "US"), or
|
||||
* null when ICU has none or maps it to the multi-country "001" ("Etc/UTC").
|
||||
*
|
||||
* This is the `android.icu`-backed bridge the pure-JVM
|
||||
* [de.jeanlucmakiola.calendula.domain.timeZoneOptions] takes as `regionOf`, so
|
||||
* it can ask ICU for a zone's abbreviation in that zone's own region — the only
|
||||
* region for which ICU will surface it (see `resolveAbbreviation`). Lives here,
|
||||
* not in `domain/`, precisely because it needs an Android import.
|
||||
*/
|
||||
fun icuTimeZoneRegion(id: String): String? =
|
||||
runCatching { IcuTimeZone.getRegion(id) }.getOrNull()?.takeIf { it.length == 2 }
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Today
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/**
|
||||
* The top-bar "jump to today" icon button, shared by every calendar view's app
|
||||
* bar (#60). Shown in place of the fade-in FAB pill when the user moves the
|
||||
* today control into the toolbar; renders nothing when [show] is false, so it
|
||||
* drops straight into an app bar's `actions` slot without a wrapping condition.
|
||||
*/
|
||||
@Composable
|
||||
fun TodayAction(show: Boolean, onToday: () -> Unit) {
|
||||
if (!show) return
|
||||
IconButton(onClick = onToday) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Today,
|
||||
contentDescription = stringResource(R.string.today_jump_action),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -43,7 +42,6 @@ import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -68,8 +66,10 @@ 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.ui.common.formatCalendarTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
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
|
||||
@@ -77,13 +77,14 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||
@@ -93,7 +94,9 @@ import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -125,6 +128,7 @@ fun DayScreen(
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
todayInToolbar: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
initialDateIso: String? = null,
|
||||
viewModel: DayViewModel = hiltViewModel(),
|
||||
@@ -157,6 +161,13 @@ fun DayScreen(
|
||||
else -> true
|
||||
}
|
||||
|
||||
// Drives whether the title carries the year. Falls back to the clock only
|
||||
// while the first load is in flight, when there is no state to read today from.
|
||||
val currentYear = when (val s = state) {
|
||||
is DayUiState.Success -> s.today.year
|
||||
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
||||
}
|
||||
|
||||
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
|
||||
var slideDir by remember { mutableIntStateOf(0) }
|
||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||
@@ -205,16 +216,19 @@ fun DayScreen(
|
||||
topBar = {
|
||||
DayTopBar(
|
||||
date = date,
|
||||
currentYear = currentYear,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = jumpToToday,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
CalendarFabColumn(
|
||||
todayVisible = !isOnToday,
|
||||
todayVisible = !isOnToday && !todayInToolbar,
|
||||
todayText = stringResource(R.string.day_today_action),
|
||||
onToday = jumpToToday,
|
||||
onCreate = { onCreateEvent(date, null) },
|
||||
@@ -251,8 +265,6 @@ private fun DayContent(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val threshold = with(density) { 24.dp.toPx() }
|
||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
@@ -281,20 +293,7 @@ private fun DayContent(
|
||||
// Whole-page horizontal swipe, one level above the timeline's vertical
|
||||
// scroll: a horizontal drag crosses this detector's slop, while a vertical
|
||||
// drag is consumed by the inner scroll first — the two gestures coexist.
|
||||
val swipeModifier = Modifier.pointerInput(Unit) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { dragAccum = 0f },
|
||||
onDragEnd = {
|
||||
when {
|
||||
dragAccum < -threshold -> onSwipeNext()
|
||||
dragAccum > threshold -> onSwipePrev()
|
||||
}
|
||||
dragAccum = 0f
|
||||
},
|
||||
onDragCancel = { dragAccum = 0f },
|
||||
onHorizontalDrag = { _, drag -> dragAccum += drag },
|
||||
)
|
||||
}
|
||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
@@ -360,16 +359,20 @@ private fun DaySuccess(
|
||||
@Composable
|
||||
private fun DayTopBar(
|
||||
date: LocalDate,
|
||||
currentYear: Int,
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = formatDayTitle(date),
|
||||
text = formatDayTitle(date, locale, currentYear),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
@@ -382,6 +385,7 @@ private fun DayTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TodayAction(show = showTodayButton, onToday = onToday)
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
@@ -669,10 +673,10 @@ private fun DayLoading() {
|
||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
||||
formatMinuteOfDay(min, is24Hour, locale)
|
||||
|
||||
private fun formatDayTitle(date: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
return "$weekday, ${date.day}. $monthName ${date.year}"
|
||||
}
|
||||
private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String =
|
||||
formatCalendarTitle(
|
||||
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
|
||||
locale = locale,
|
||||
currentYear = currentYear,
|
||||
skeleton = "EEEdMMM",
|
||||
)
|
||||
|
||||
@@ -93,22 +93,26 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.floret.components.OptionCard
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlin.time.toJavaInstant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
@@ -453,14 +457,44 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
}
|
||||
|
||||
// Time zone — only when the event is timed and pinned to a zone other
|
||||
// than the device's, so cross-zone events read unambiguously.
|
||||
foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel ->
|
||||
// than the device's, so cross-zone events read unambiguously. It answers
|
||||
// "when was it set", which the zone's name alone never did: an 8 AM New
|
||||
// York call showing as 2 PM here should still say 8 AM somewhere.
|
||||
//
|
||||
// Same hierarchy as the When card above — the label carries the card,
|
||||
// the time sits small beneath it. The local time is the one the reader
|
||||
// acts on, so the original must stay quieter than it, not compete.
|
||||
val foreignZone = remember(detail.eventTimezone, instance.isAllDay, instance.start, locale) {
|
||||
foreignTimeZone(detail.eventTimezone, instance.isAllDay, locale, instance.start)
|
||||
}
|
||||
foreignZone?.let { zoneOption ->
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
icon = Icons.Default.Public,
|
||||
iconContentDescription = stringResource(R.string.event_detail_timezone),
|
||||
) {
|
||||
Text(text = tzLabel, style = MaterialTheme.typography.titleMedium)
|
||||
// The id is the primary label ("Europe/Berlin"); the long
|
||||
// localized name spelled next to it made the field too wide.
|
||||
Text(text = zoneOption.label, style = MaterialTheme.typography.titleMedium)
|
||||
// Beneath, small: the abbreviation plus the event's own-zone time,
|
||||
// e.g. "CET · 8:00 AM – 9:00 AM". formatWhen puts the range in the
|
||||
// secondary half only for a same-day event; one spanning midnight
|
||||
// — which a cross-zone event easily does — carries the whole span
|
||||
// in the primary instead, so fall back to it. If the zone can't be
|
||||
// read back at all, drop to just the abbreviation + offset.
|
||||
val originalTime = runCatching { TimeZone.of(zoneOption.id) }.getOrNull()
|
||||
?.let { formatWhen(instance, it, locale) }
|
||||
?.let { (primary, secondary) -> secondary ?: primary }
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = if (originalTime != null) {
|
||||
"${zoneOption.shortName} · $originalTime"
|
||||
} else {
|
||||
zoneDescriptor(zoneOption)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,21 +786,30 @@ private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
|
||||
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
|
||||
|
||||
/**
|
||||
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
|
||||
* but only when the event is timed and pinned to a zone different from the
|
||||
* device's. Returns null when there's nothing worth showing.
|
||||
* [tz] resolved as a [TimeZoneOption], but only when the event is timed and
|
||||
* pinned to a zone different from the device's — the cases where showing it
|
||||
* removes ambiguity. Null otherwise (all-day, device-zone, blank, or an id the
|
||||
* tz database doesn't know).
|
||||
*
|
||||
* Resolved *at [at]*, the event's own start, not at "now": the abbreviation and
|
||||
* offset both move with DST, and the card prints them next to the event's time.
|
||||
* Resolving at now would label a July event "CET · 10:00 AM" when read in
|
||||
* January — an abbreviation that contradicts the time beside it.
|
||||
*/
|
||||
private fun foreignTimeZoneLabel(tz: String?, isAllDay: Boolean, locale: Locale): String? {
|
||||
private fun foreignTimeZone(
|
||||
tz: String?,
|
||||
isAllDay: Boolean,
|
||||
locale: Locale,
|
||||
at: Instant,
|
||||
): TimeZoneOption? {
|
||||
if (isAllDay || tz.isNullOrBlank()) return null
|
||||
val deviceZone = ZoneId.systemDefault().id
|
||||
if (tz == deviceZone) return null
|
||||
return try {
|
||||
val zone = ZoneId.of(tz)
|
||||
val name = zone.getDisplayName(JavaTextStyle.FULL, locale)
|
||||
if (name == tz) tz else "$name ($tz)"
|
||||
} catch (e: Exception) {
|
||||
tz
|
||||
}
|
||||
if (tz == ZoneId.systemDefault().id) return null
|
||||
return timeZoneOptionOf(
|
||||
tz,
|
||||
locale,
|
||||
at = at.toJavaInstant(),
|
||||
regionOf = ::icuTimeZoneRegion,
|
||||
)
|
||||
}
|
||||
|
||||
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */
|
||||
|
||||
@@ -98,7 +98,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@@ -111,6 +110,9 @@ import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||
import de.jeanlucmakiola.calendula.domain.timesIn
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
||||
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
||||
@@ -126,6 +128,8 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||
@@ -134,16 +138,19 @@ import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.OptionCard
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.groupedShape as floretGroupedShape
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
@@ -157,13 +164,14 @@ import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.isoDayNumber
|
||||
import kotlinx.datetime.toJavaDayOfWeek
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toKotlinDayOfWeek
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toJavaLocalTime
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.toJavaInstant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.time.temporal.WeekFields
|
||||
import java.util.Locale
|
||||
import kotlin.time.Clock
|
||||
|
||||
@@ -495,6 +503,7 @@ private fun EventEditContent(
|
||||
) {
|
||||
val form = state.form
|
||||
val locale = currentLocale()
|
||||
val firstDayOfWeek by viewModel.firstDayOfWeek.collectAsStateWithLifecycle()
|
||||
val dark = isSystemInDarkTheme()
|
||||
// The title, date and recurrence are managed by the special-dates sync, so
|
||||
// they're locked here; everything else (reminders, location, notes) is the
|
||||
@@ -506,6 +515,7 @@ private fun EventEditContent(
|
||||
var showRecurrencePicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showColorPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showTimezonePicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Pick a guest from contacts: a one-shot system picker returning the chosen
|
||||
@@ -670,6 +680,28 @@ private fun EventEditContent(
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
// The rows above edit a pinned event in *its own* zone, which is the
|
||||
// time it was set at — but that says nothing about when it lands for
|
||||
// whoever is reading it. Spell the local equivalent out rather than
|
||||
// leaving the user to do the offset arithmetic. Absent (null) unless
|
||||
// the event is pinned somewhere other than here.
|
||||
// Keyed rather than recomputed: this sits in the same Column as the
|
||||
// title field, so it would otherwise re-parse the zone on every
|
||||
// keystroke.
|
||||
val localTimes = remember(form.timezone, form.start, form.end, form.isAllDay) {
|
||||
form.timesIn(TimeZone.currentSystemDefault())
|
||||
}
|
||||
localTimes?.let { (localStart, localEnd) ->
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.event_edit_timezone_local_time,
|
||||
formatTimeRange(localStart, localEnd, locale),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(gap))
|
||||
@@ -706,6 +738,54 @@ private fun EventEditContent(
|
||||
|
||||
// Optional sections: which ones render by default is a setting; the
|
||||
// rest unfold behind the "more fields" button below.
|
||||
OptionalFormSection(visible = EventFormField.Timezone in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
EditCard(
|
||||
icon = Icons.Default.Public,
|
||||
iconContentDescription = null,
|
||||
onClick = { showTimezonePicker = true },
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
// Resolved at the event's own start, not at "now": the
|
||||
// abbreviation and offset shown ("CEST · GMT+02:00") must be
|
||||
// the ones that apply when the event actually happens.
|
||||
val pinned = remember(form.timezone, form.start, locale) {
|
||||
form.timezone
|
||||
?.let { id -> runCatching { id to TimeZone.of(id) }.getOrNull() }
|
||||
?.let { (id, zone) ->
|
||||
timeZoneOptionOf(
|
||||
id,
|
||||
locale,
|
||||
at = form.start.toInstant(zone).toJavaInstant(),
|
||||
regionOf = ::icuTimeZoneRegion,
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = pinned?.label
|
||||
?: stringResource(R.string.event_edit_timezone_device),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = pinned?.let { zoneDescriptor(it) }
|
||||
?: stringResource(R.string.event_edit_timezone_device_summary),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OptionalFormSection(visible = EventFormField.Location in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
EditCard(
|
||||
@@ -1062,6 +1142,7 @@ private fun EventEditContent(
|
||||
RecurrencePickerDialog(
|
||||
current = form.rrule,
|
||||
startDay = form.start.date.dayOfWeek,
|
||||
firstDayOfWeek = firstDayOfWeek,
|
||||
onSelect = { rrule ->
|
||||
viewModel.setRecurrence(rrule)
|
||||
showRecurrencePicker = false
|
||||
@@ -1081,6 +1162,16 @@ private fun EventEditContent(
|
||||
)
|
||||
}
|
||||
|
||||
if (showTimezonePicker) {
|
||||
TimeZonePickerDialog(
|
||||
selected = form.timezone,
|
||||
deviceZoneId = ZoneId.systemDefault().id,
|
||||
recents = state.recentTimeZones,
|
||||
onSelect = viewModel::setTimezone,
|
||||
onDismiss = { showTimezonePicker = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showColorPicker) {
|
||||
ColorPickerDialog(
|
||||
palette = state.colorPalette,
|
||||
@@ -1223,6 +1314,7 @@ private enum class RecurrenceEndMode { Never, Until, Count }
|
||||
private fun RecurrencePickerDialog(
|
||||
current: String?,
|
||||
startDay: DayOfWeek,
|
||||
firstDayOfWeek: DayOfWeek,
|
||||
onSelect: (String?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
@@ -1257,8 +1349,21 @@ private fun RecurrencePickerDialog(
|
||||
|
||||
val locale = currentLocale()
|
||||
val untilDate = untilIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
||||
val interval = intervalText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||
val count = countText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||
// remember() must not sit behind the ?. — a slot that appears and disappears
|
||||
// as a date is picked breaks Compose's positional memoisation. Format
|
||||
// unconditionally and let the null fall through to a null summary.
|
||||
val untilSummary = remember(untilDate, locale) {
|
||||
untilDate?.let {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
.withLocale(locale).format(it.toJavaLocalDate())
|
||||
}
|
||||
}
|
||||
// A blank amount field shows its placeholder ("1" / "10") greyed out, so read
|
||||
// blank as that default rather than as an error — otherwise simply clearing
|
||||
// the field silently greys out OK. Only a real out-of-range value (0) is
|
||||
// invalid, and that's the one case the read-out below reports.
|
||||
val interval = if (intervalText.isBlank()) 1 else intervalText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||
val count = if (countText.isBlank()) 10 else countText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||
val customEnd: RecurrenceEnd? = when (endMode) {
|
||||
RecurrenceEndMode.Never -> RecurrenceEnd.Never
|
||||
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
|
||||
@@ -1294,6 +1399,11 @@ private fun RecurrencePickerDialog(
|
||||
title = stringResource(R.string.event_edit_recurrence_none),
|
||||
position = positionOf(0, rowCount),
|
||||
selected = current == null,
|
||||
trailing = if (current == null) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { onSelect(null) },
|
||||
)
|
||||
RecurrenceFreq.entries.forEachIndexed { index, entry ->
|
||||
@@ -1301,6 +1411,11 @@ private fun RecurrencePickerDialog(
|
||||
title = stringResource(recurrencePresetLabel(entry)),
|
||||
position = positionOf(index + 1, rowCount),
|
||||
selected = isPlainPreset && parsed?.freq == entry,
|
||||
trailing = if (isPlainPreset && parsed?.freq == entry) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
|
||||
)
|
||||
}
|
||||
@@ -1308,12 +1423,54 @@ private fun RecurrencePickerDialog(
|
||||
title = stringResource(R.string.event_edit_recurrence_custom),
|
||||
position = positionOf(rowCount - 1, rowCount),
|
||||
selected = current != null && !isPlainPreset,
|
||||
trailing = if (current != null && !isPlainPreset) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { customMode = true },
|
||||
)
|
||||
} else {
|
||||
// Section gaps live on each block (not a parent spacedBy) so the
|
||||
// weekday card's gap collapses *with* it under AnimatedVisibility —
|
||||
// a parent arrangement would leave a residual gap that snaps shut.
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
// A live, human-readable read-out of the rule being built, so the
|
||||
// effect of the controls below is never a guess. It renders
|
||||
// *[customResult] itself* — the exact string OK would save — so it
|
||||
// can never describe a different rule than the one that lands. When
|
||||
// the form can't produce a rule, it says why instead of falling
|
||||
// back to defaults and quietly disagreeing with the controls.
|
||||
// minLines reserves the second line up front: without it the whole
|
||||
// stack below shifts a line as the phrase grows with each weekday.
|
||||
Text(
|
||||
text = customResult?.let { recurrenceText(it, locale) }
|
||||
?: AnnotatedString(
|
||||
stringResource(R.string.event_edit_recurrence_incomplete),
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (customResult != null) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
},
|
||||
minLines = 2,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
// How often: an interval amount plus a frequency segmented row —
|
||||
// all four units on show, no unit hidden behind a dropdown.
|
||||
GroupedSurface(
|
||||
position = Position.Alone,
|
||||
modifier = Modifier
|
||||
.padding(top = 16.dp)
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
@@ -1326,44 +1483,86 @@ private fun RecurrencePickerDialog(
|
||||
onValueChange = { intervalText = it },
|
||||
placeholder = "1",
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
DialogUnitDropdown(
|
||||
label = stringResource(recurrenceUnitLabel(freq)),
|
||||
entries = RecurrenceFreq.entries.map {
|
||||
stringResource(recurrenceUnitLabel(it))
|
||||
}
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
RecurrenceFreq.entries.forEachIndexed { index, entry ->
|
||||
SegmentedButton(
|
||||
selected = freq == entry,
|
||||
onClick = { freq = entry },
|
||||
shape = SegmentedButtonDefaults.itemShape(
|
||||
index, RecurrenceFreq.entries.size,
|
||||
),
|
||||
// Drop the default check-icon slot: four segments
|
||||
// share one row, and its reserved width is what
|
||||
// pushes longer labels ("Monate", "Wochen") into
|
||||
// an ellipsis on a compact screen. Selection is
|
||||
// already carried by the segment's fill.
|
||||
icon = {},
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(recurrenceUnitLabel(entry)),
|
||||
maxLines = 1,
|
||||
)
|
||||
},
|
||||
onPick = { freq = RecurrenceFreq.entries[it] },
|
||||
)
|
||||
}
|
||||
if (freq == RecurrenceFreq.Weekly) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Weekday picks — only meaningful on a weekly rule. The 16dp gap
|
||||
// sits inside the animated block so it grows/shrinks with the card.
|
||||
AnimatedVisibility(
|
||||
visible = freq == RecurrenceFreq.Weekly,
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
GroupedSurface(
|
||||
position = Position.Alone,
|
||||
modifier = Modifier
|
||||
.padding(top = 16.dp)
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
WeekdayToggleRow(
|
||||
selected = daysMask.toDaySet(),
|
||||
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
|
||||
locale = locale,
|
||||
firstDay = firstDayOfWeek,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Ends: a connected never / on-a-date / after-N group, the count
|
||||
// field folding into the bottom of the run when chosen.
|
||||
Column(modifier = Modifier.padding(top = 16.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.event_edit_recurrence_ends),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.event_edit_recurrence_end_never),
|
||||
position = positionOf(0, 3),
|
||||
position = Position.Top,
|
||||
selected = endMode == RecurrenceEndMode.Never,
|
||||
trailing = if (endMode == RecurrenceEndMode.Never) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { endMode = RecurrenceEndMode.Never },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.event_edit_recurrence_end_until),
|
||||
summary = untilDate?.let {
|
||||
remember(it, locale) {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
.withLocale(locale).format(it.toJavaLocalDate())
|
||||
}
|
||||
},
|
||||
position = positionOf(1, 3),
|
||||
summary = untilSummary,
|
||||
position = Position.Middle,
|
||||
selected = endMode == RecurrenceEndMode.Until,
|
||||
trailing = if (endMode == RecurrenceEndMode.Until) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
endMode = RecurrenceEndMode.Until
|
||||
showUntilPicker = true
|
||||
@@ -1371,14 +1570,34 @@ private fun RecurrencePickerDialog(
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.event_edit_recurrence_end_count),
|
||||
position = positionOf(2, 3),
|
||||
position = if (endMode == RecurrenceEndMode.Count) {
|
||||
Position.Middle
|
||||
} else {
|
||||
Position.Bottom
|
||||
},
|
||||
selected = endMode == RecurrenceEndMode.Count,
|
||||
trailing = if (endMode == RecurrenceEndMode.Count) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { endMode = RecurrenceEndMode.Count },
|
||||
)
|
||||
if (endMode == RecurrenceEndMode.Count) {
|
||||
// Animated for the same reason the weekday card is: this is the
|
||||
// picker's other collapsible block, and a bare `if` here made
|
||||
// the ends group snap while the card above it glided.
|
||||
AnimatedVisibility(
|
||||
visible = endMode == RecurrenceEndMode.Count,
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
GroupedSurface(
|
||||
position = Position.Bottom,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) {
|
||||
DialogAmountField(
|
||||
value = countText,
|
||||
@@ -1394,6 +1613,9 @@ private fun RecurrencePickerDialog(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showUntilPicker) {
|
||||
CalendarDatePickerDialog(
|
||||
@@ -1404,7 +1626,13 @@ private fun RecurrencePickerDialog(
|
||||
untilIso = it.toString()
|
||||
showUntilPicker = false
|
||||
},
|
||||
onDismiss = { showUntilPicker = false },
|
||||
onDismiss = {
|
||||
showUntilPicker = false
|
||||
// Backing out of the date picker with nothing chosen would leave
|
||||
// "on date" selected but dateless — a state that can't be saved and
|
||||
// greys out OK with no visible cause. Fall back to "never".
|
||||
if (untilIso == null) endMode = RecurrenceEndMode.Never
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1419,14 +1647,15 @@ private fun WeekdayToggleRow(
|
||||
selected: Set<DayOfWeek>,
|
||||
onToggle: (DayOfWeek) -> Unit,
|
||||
locale: Locale,
|
||||
firstDay: DayOfWeek,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val days = remember(locale) {
|
||||
val first = WeekFields.of(locale).firstDayOfWeek.toKotlinDayOfWeek()
|
||||
(0 until 7).map { DayOfWeek(((first.isoDayNumber - 1 + it) % 7) + 1) }
|
||||
val days = remember(firstDay) {
|
||||
(0 until 7).map { DayOfWeek(((firstDay.isoDayNumber - 1 + it) % 7) + 1) }
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
days.forEach { day ->
|
||||
val isSelected = day in selected
|
||||
@@ -1479,18 +1708,14 @@ private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) {
|
||||
RecurrenceFreq.Yearly -> R.string.recurrence_unit_years
|
||||
}
|
||||
|
||||
/** Corner shape for a card at [position] in a grouped run (mirrors GroupedRow). */
|
||||
private fun groupedShape(position: Position, full: Dp = 20.dp, small: Dp = 6.dp): Shape =
|
||||
when (position) {
|
||||
Position.Alone -> RoundedCornerShape(full)
|
||||
Position.Top -> RoundedCornerShape(
|
||||
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
|
||||
)
|
||||
Position.Middle -> RoundedCornerShape(small)
|
||||
Position.Bottom -> RoundedCornerShape(
|
||||
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Corner shape for a card at [position] in a grouped run. Delegates to the
|
||||
* family primitive with the same radii [GroupedSurface] draws at rest — a local
|
||||
* copy had drifted to 20dp, so a card sharing a run with GroupedRows had
|
||||
* visibly tighter outer corners than the rows above it.
|
||||
*/
|
||||
private fun groupedShape(position: Position): Shape =
|
||||
floretGroupedShape(position, full = 22.dp, small = 6.dp)
|
||||
|
||||
/** The 2dp gap that visually separates grouped cards (none after the last). */
|
||||
private fun groupedGap(position: Position): Modifier = when (position) {
|
||||
@@ -1933,6 +2158,21 @@ private fun EditCard(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "2:00 PM – 3:00 PM", honouring the user's 12/24-hour setting. Collapses to one
|
||||
* time when both land on the same minute (an instant event shouldn't read as a
|
||||
* range against itself). Dates are deliberately absent — the rows above carry
|
||||
* them.
|
||||
*/
|
||||
@Composable
|
||||
private fun formatTimeRange(start: LocalDateTime, end: LocalDateTime, locale: Locale): String {
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val timeFormat = remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }
|
||||
val from = timeFormat.format(start.time.toJavaLocalTime())
|
||||
val to = timeFormat.format(end.time.toJavaLocalTime())
|
||||
return if (from == to) from else "$from – $to"
|
||||
}
|
||||
|
||||
/**
|
||||
* Borderless text input used inside the cards (and as the headline title).
|
||||
* Thin wrapper over the shared [InlineTextField] so the form and the rest of
|
||||
|
||||
@@ -27,6 +27,8 @@ data class EventEditUiState(
|
||||
* neither list.
|
||||
*/
|
||||
val hiddenFields: List<EventFormField> = emptyList(),
|
||||
/** Recently picked zones, most recent first — the zone picker's shortlist. */
|
||||
val recentTimeZones: List<String> = emptyList(),
|
||||
/** True while editing an existing event (the calendar is then fixed). */
|
||||
val isEditing: Boolean = false,
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
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.firstDayOfWeek
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
@@ -39,6 +40,8 @@ import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
@@ -219,7 +222,8 @@ class EventEditViewModel @Inject constructor(
|
||||
).flowOn(io),
|
||||
colorPalette,
|
||||
allCalendars,
|
||||
) { local, external, palette, allCalendars ->
|
||||
settingsPrefs.recentTimeZones.flowOn(io),
|
||||
) { local, external, palette, allCalendars, recentTimeZones ->
|
||||
val form = local.form ?: return@combine null
|
||||
val resolvedId = form.calendarId
|
||||
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
||||
@@ -235,14 +239,19 @@ class EventEditViewModel @Inject constructor(
|
||||
val pickerCalendars =
|
||||
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
|
||||
else external.writable
|
||||
val visibleFields = external.defaultFields + local.revealed
|
||||
// An all-day event is date-anchored, so a zone is meaningless on it —
|
||||
// the field is withheld from both lists rather than shown as a no-op.
|
||||
val offerableFields = EventFormField.entries.toSet() -
|
||||
if (resolved.isAllDay) setOf(EventFormField.Timezone) else emptySet()
|
||||
val visibleFields = (external.defaultFields + local.revealed) intersect offerableFields
|
||||
EventEditUiState(
|
||||
form = resolved,
|
||||
calendars = pickerCalendars,
|
||||
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
||||
saveState = local.saveState,
|
||||
visibleFields = visibleFields,
|
||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
||||
hiddenFields = (offerableFields - visibleFields).sorted(),
|
||||
recentTimeZones = recentTimeZones,
|
||||
isEditing = local.editTarget != null,
|
||||
isManaged = isManaged,
|
||||
autofocusTitle = external.autofocusTitle,
|
||||
@@ -260,6 +269,13 @@ class EventEditViewModel @Inject constructor(
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* First day of the week for ordering the recurrence weekday toggles — the
|
||||
* app's "week starts on" preference, matching the calendar views (Auto
|
||||
* falls back to the locale convention).
|
||||
*/
|
||||
val firstDayOfWeek: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||
|
||||
/**
|
||||
* Initialise a fresh form for a new event on [date]. [startMinutes] (minutes
|
||||
* from midnight) anchors the start when the form is opened by tapping a slot
|
||||
@@ -453,12 +469,28 @@ class EventEditViewModel @Inject constructor(
|
||||
fun setLocation(value: String) = update { it.copy(location = value) }
|
||||
fun setDescription(value: String) = update { it.copy(description = value) }
|
||||
fun setAllDay(value: Boolean) {
|
||||
update { it.copy(isAllDay = value) }
|
||||
// Going all-day drops any pinned zone: the times become bare dates that
|
||||
// the provider stores UTC-anchored, so a zone could only misrepresent
|
||||
// them. Coming back out of all-day leaves the event on the device zone,
|
||||
// which is the same thing a fresh event gets.
|
||||
update { it.copy(isAllDay = value, timezone = if (value) null else it.timezone) }
|
||||
// The default reminder differs for all-day vs timed; re-apply the
|
||||
// type-appropriate default unless the user has hand-edited it (guarded).
|
||||
applyDefaultReminder()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin the event to [zoneId], or pass null to follow the device. The zone
|
||||
* rides along as a recent so the picker can offer it without a search next
|
||||
* time — only real picks are remembered, not the device fallback.
|
||||
*/
|
||||
fun setTimezone(zoneId: String?) {
|
||||
update { it.copy(timezone = zoneId) }
|
||||
if (zoneId != null) {
|
||||
viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switching calendars drops any chosen colour: a palette key is
|
||||
* account-scoped, and a raw colour may be invalid on the new calendar.
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* What lets the split style's compact grid *become* the full month rather than be
|
||||
* swapped for it (#53).
|
||||
*
|
||||
* The two grids are separate composables — the compact one draws dots, the
|
||||
* expanded one draws the paged style's bars and pills — so nothing about them is
|
||||
* shared by construction. Tagging the pieces that mean the same thing on both
|
||||
* sides with the same [MonthMorphKey] hands Compose enough to animate one into
|
||||
* the other: a day's cell grows, its number rides along, and each dot travels out
|
||||
* to the bar it stood for.
|
||||
*
|
||||
* It travels as a composition local rather than as parameters because the pieces
|
||||
* sit five levels below where the scopes exist, and because a null default is
|
||||
* exactly the right meaning for everyone else: the paged, continuous, and dense
|
||||
* styles share the same row and cell composables, provide nothing, and pay
|
||||
* nothing. Reduced motion provides nothing either, which leaves the plain
|
||||
* cross-fade underneath.
|
||||
*/
|
||||
internal sealed interface MonthMorphKey {
|
||||
/** A day's background pill — the structural anchor the rest rides on. */
|
||||
data class Cell(val date: LocalDate) : MonthMorphKey
|
||||
|
||||
data class DayNumber(val date: LocalDate) : MonthMorphKey
|
||||
|
||||
/**
|
||||
* One event *on one day*. Keyed by date as well as instance because a
|
||||
* multi-day event has a dot on every day it covers but only one bar, drawn
|
||||
* from where it starts: the start day's dot is the one that becomes the bar,
|
||||
* and the rest are left unmatched on purpose. They fade where they stand
|
||||
* while the bar sweeps out over them, which is the honest reading — one of
|
||||
* them could not become the bar without the others teleporting into it.
|
||||
*/
|
||||
data class Event(val date: LocalDate, val instanceId: Long) : MonthMorphKey
|
||||
|
||||
/**
|
||||
* A day's "+N" marker. The compact grid writes it as a count beside the dots
|
||||
* and the expanded one as a row of small dots under the bars, but they stand
|
||||
* for the same events and appear on exactly the same days — both sides seat
|
||||
* three and overflow the rest — so they are a matched pair, not two unrelated
|
||||
* bits of text. Tagged, the marker travels with its day like everything else
|
||||
* in the cell.
|
||||
*/
|
||||
data class Overflow(val date: LocalDate) : MonthMorphKey
|
||||
|
||||
/**
|
||||
* The selected day's outline. The expanded grid draws no outline — once the
|
||||
* cells carry real event bars it is one mark too many — so this pairs the
|
||||
* compact grid's outline with an *invisible* stand-in over the same cell
|
||||
* there. Without one it had nowhere to travel and could only fade in place
|
||||
* while everything around it moved.
|
||||
*/
|
||||
data class Selection(val date: LocalDate) : MonthMorphKey
|
||||
|
||||
/** The grab handle, which slides from the pane's seam to the foot of the grid. */
|
||||
data object Handle : MonthMorphKey
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
internal class MonthMorphScope(
|
||||
val shared: SharedTransitionScope,
|
||||
val visibility: AnimatedVisibilityScope,
|
||||
)
|
||||
|
||||
internal val LocalMonthMorph = compositionLocalOf<MonthMorphScope?> { null }
|
||||
|
||||
/**
|
||||
* True while the grid is mid-morph, for content that has to stop clipping to let
|
||||
* the travelling pieces through.
|
||||
*
|
||||
* A mark's two homes are in *different rows*: the dot for the 20th sits a third
|
||||
* of the way down the compact grid, its bar most of the way down the expanded
|
||||
* one. Clipped to the row it is arriving at, a mark spends the first half of its
|
||||
* journey outside those bounds and is simply not drawn — so it appears from
|
||||
* nowhere, part-way through, already near its destination. Rendering in place
|
||||
* rather than in an overlay is what subjects them to that clip, and is worth
|
||||
* keeping; the clip is what has to yield, and only while pieces are in flight.
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
internal fun morphInFlight(): Boolean {
|
||||
val morph = LocalMonthMorph.current ?: return false
|
||||
return morph.shared.isTransitionActive
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag content that is *the same thing* on both sides — a background pill, a day
|
||||
* number — so it animates between its two positions and sizes.
|
||||
*
|
||||
* ### Why nothing renders in the overlay
|
||||
*
|
||||
* By default a travelling piece is painted into an overlay above the *entire*
|
||||
* regular tree, so it can fly over anything in its way. That is right for a
|
||||
* thumbnail crossing a screen, and wrong here: everything is moving inside one
|
||||
* grid, and the overlay meant every untagged neighbour — the selection outline,
|
||||
* the "+N" markers — spent the transition buried under pieces that had left the
|
||||
* tree's z-order behind. Lifting each of them out in turn fixed the burying and
|
||||
* bought a worse problem: they then floated over the grid on their own layer,
|
||||
* out of step with it.
|
||||
*
|
||||
* Rendering in place puts everything back in one z-order and one clip, which is
|
||||
* what makes the grid read as a single surface changing shape rather than a
|
||||
* stack of pieces sliding past each other. Nothing here needs to escape its
|
||||
* ancestors: the cells, marks and markers all travel within the grid.
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
internal fun Modifier.morphElement(key: MonthMorphKey): Modifier {
|
||||
val morph = LocalMonthMorph.current ?: return this
|
||||
return with(morph.shared) {
|
||||
this@morphElement.sharedElement(
|
||||
sharedContentState = rememberSharedContentState(key),
|
||||
animatedVisibilityScope = morph.visibility,
|
||||
renderInOverlayDuringTransition = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag content that means the same thing but *is drawn differently* on each side —
|
||||
* a 5dp dot and a titled bar — so only the bounds are shared and the contents
|
||||
* cross-fade inside them.
|
||||
*
|
||||
* [ResizeMode.RemeasureToBounds][SharedTransitionScope.ResizeMode] rather than
|
||||
* scaling: a bar's title laid out at the dot's 5dp and then scaled up would
|
||||
* arrive as a smear. Remeasuring keeps the text at its real size throughout and
|
||||
* simply clips it while there is no room, so what grows is the pill, not the type.
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier {
|
||||
val morph = LocalMonthMorph.current ?: return this
|
||||
return with(morph.shared) {
|
||||
this@morphBounds.sharedBounds(
|
||||
sharedContentState = rememberSharedContentState(key),
|
||||
animatedVisibilityScope = morph.visibility,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
||||
renderInOverlayDuringTransition = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,284 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings
|
||||
* chooser.
|
||||
*
|
||||
* It renders the *real* grid composables rather than a drawing of them, so the
|
||||
* preview cannot drift from the thing it depicts: change a cell's shape or an
|
||||
* event bar's colour and every preview follows automatically. The trick is
|
||||
* [requiredSize] — it ignores the incoming constraints, so the grid lays itself
|
||||
* out at a full phone width and a plausible viewport height, and a
|
||||
* [graphicsLayer] scale shrinks the finished layout into the card.
|
||||
*
|
||||
* The month, today's position and the week start are all real; only the events
|
||||
* are stand-ins, since the settings screen has no business querying the
|
||||
* provider for a thumbnail.
|
||||
*/
|
||||
@Composable
|
||||
internal fun MonthStylePreview(
|
||||
style: MonthViewStyle,
|
||||
weekStart: DayOfWeek,
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val scale = height.value / VIRTUAL_HEIGHT.value
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||
val sample = remember(today, weekStart, zone) { sampleMonthState(today, weekStart, zone) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clipToBounds()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
// A preview is a picture, not a control: swallow touches before the
|
||||
// grid's own clickables see them, and give screen readers one label
|
||||
// instead of six weeks of day cells.
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent(PointerEventPass.Initial).changes
|
||||
.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.clearAndSetSemantics { }
|
||||
// Measure the grid at a full phone viewport but report the scaled
|
||||
// size, so the node occupies exactly what it draws.
|
||||
//
|
||||
// Modifier.requiredSize would be the obvious way to force the larger
|
||||
// measurement, but it *centres* content that overflows the incoming
|
||||
// constraints — which pushed the grid to a negative offset and left
|
||||
// only its bottom-right corner inside the clip.
|
||||
.layout { measurable, _ ->
|
||||
val fullWidth = screenWidth.roundToPx()
|
||||
val fullHeight = VIRTUAL_HEIGHT.roundToPx()
|
||||
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
|
||||
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
}
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
},
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
WeekdayHeader(weekStart = weekStart, showWeekNumbers = false)
|
||||
when (style) {
|
||||
MonthViewStyle.Paged -> MonthGrid(
|
||||
state = sample.month,
|
||||
showWeekNumbers = false,
|
||||
onOpenDay = {},
|
||||
)
|
||||
MonthViewStyle.Continuous -> ContinuousMonthGrid(
|
||||
state = sample.continuous,
|
||||
listState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = itemIndexForMonth(
|
||||
monthIndexOf(YearMonth(today.year, today.month)),
|
||||
),
|
||||
),
|
||||
showWeekNumbers = false,
|
||||
onOpenDay = {},
|
||||
)
|
||||
MonthViewStyle.Dense -> DenseMonthGrid(
|
||||
state = sample.continuous,
|
||||
listState = rememberLazyListState(
|
||||
// Start a week above today's, so the preview shows a
|
||||
// stream running past the viewport rather than one
|
||||
// beginning at its top edge.
|
||||
initialFirstVisibleItemIndex =
|
||||
weekIndexOf(today, weekStart) - 1,
|
||||
),
|
||||
showWeekNumbers = false,
|
||||
onOpenDay = {},
|
||||
)
|
||||
MonthViewStyle.Split -> {
|
||||
SplitMonthGrid(
|
||||
state = sample.month,
|
||||
selected = today,
|
||||
showWeekNumbers = false,
|
||||
onSelectDay = {},
|
||||
)
|
||||
SplitDayPane(
|
||||
date = today,
|
||||
today = today,
|
||||
events = sample.month.instancesByDay[today].orEmpty(),
|
||||
zone = zone,
|
||||
onOpenDay = {},
|
||||
onEventClick = {},
|
||||
onCreateEvent = {},
|
||||
modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The viewport the preview pretends to be: a phone's width by the height a
|
||||
* calendar view gets under the top bar. Scaling from a fixed height keeps the
|
||||
* three styles comparable — each shows the same slice of screen.
|
||||
*/
|
||||
private val VIRTUAL_HEIGHT = 440.dp
|
||||
private val SPLIT_PANE_HEIGHT = 170.dp
|
||||
|
||||
private class SampleMonth(
|
||||
val month: MonthUiState.Success,
|
||||
val continuous: ContinuousMonthUiState.Success,
|
||||
)
|
||||
|
||||
/**
|
||||
* A month's worth of stand-in events, laid out through the same
|
||||
* [layoutMonthWeeks] / [clipWeekToMonth] the live views use — so the preview
|
||||
* exercises the real span, lane and overflow logic rather than approximating it.
|
||||
*
|
||||
* Colours are raw ARGB on purpose: that is what the provider hands out for an
|
||||
* event, so a token here would misrepresent what the grid actually renders.
|
||||
*/
|
||||
private fun sampleMonthState(
|
||||
today: LocalDate,
|
||||
weekStart: DayOfWeek,
|
||||
zone: TimeZone,
|
||||
): SampleMonth {
|
||||
val ym = YearMonth(today.year, today.month)
|
||||
val first = LocalDate(ym.year, ym.month, 1)
|
||||
val events = sampleEvents(first, today, zone)
|
||||
|
||||
val weeks = layoutMonthWeeks(ym, weekStart, events, zone)
|
||||
val month = MonthUiState.Success(
|
||||
month = ym,
|
||||
today = today,
|
||||
weeks = weeks,
|
||||
instancesByDay = instancesByDay(weeks.flatMap { it.days }, events, zone),
|
||||
zone = zone,
|
||||
)
|
||||
|
||||
// The month either side of this one: enough for Continuous to show a block,
|
||||
// the whitespace under it and the next month's header coming up, and for
|
||||
// Dense to have somewhere to scroll in both directions.
|
||||
val centre = monthIndexOf(ym)
|
||||
val window = (centre - 1)..(centre + 1)
|
||||
val continuous = ContinuousMonthUiState.Success(
|
||||
today = today,
|
||||
monthsByIndex = window.associateWith { index ->
|
||||
val m = yearMonthForIndex(index)
|
||||
layoutMonthWeeks(m, weekStart, events, zone).map { clipWeekToMonth(it, m) }
|
||||
},
|
||||
weeksByIndex = weekWindowFor(window, weekStart).associateWith { index ->
|
||||
val days = (0 until 7).map {
|
||||
weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY)
|
||||
}
|
||||
layoutCalendarWeek(days, events, zone)
|
||||
},
|
||||
weekStart = weekStart,
|
||||
)
|
||||
return SampleMonth(month, continuous)
|
||||
}
|
||||
|
||||
private val SAMPLE_COLORS = listOf(
|
||||
0xFF3F7BD4.toInt(),
|
||||
0xFFCE5B4C.toInt(),
|
||||
0xFF4E9A6A.toInt(),
|
||||
0xFF8A63C7.toInt(),
|
||||
)
|
||||
|
||||
private fun sampleEvents(
|
||||
firstOfMonth: LocalDate,
|
||||
today: LocalDate,
|
||||
zone: TimeZone,
|
||||
): List<EventInstance> {
|
||||
var id = 0L
|
||||
fun next() = ++id
|
||||
|
||||
fun timed(day: LocalDate, hour: Int, length: Int, colorIndex: Int) = EventInstance(
|
||||
instanceId = next(),
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
|
||||
start = day.atTime(hour, 0).toInstant(zone),
|
||||
end = day.atTime(hour + length, 0).toInstant(zone),
|
||||
isAllDay = false,
|
||||
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
||||
location = null,
|
||||
)
|
||||
|
||||
fun allDay(from: LocalDate, days: Int, colorIndex: Int) = EventInstance(
|
||||
instanceId = next(),
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
|
||||
// All-day events sit at UTC midnights with an exclusive end.
|
||||
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
end = from.plus(days, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
isAllDay = true,
|
||||
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
||||
location = null,
|
||||
)
|
||||
|
||||
// Spread across the month so most weeks carry something, with one multi-day
|
||||
// bar to show a span bridging cells and a busy today for the split pane.
|
||||
return buildList {
|
||||
add(allDay(firstOfMonth.plus(9, DateTimeUnit.DAY), days = 3, colorIndex = 2))
|
||||
add(timed(firstOfMonth.plus(1, DateTimeUnit.DAY), 9, 1, 0))
|
||||
add(timed(firstOfMonth.plus(4, DateTimeUnit.DAY), 14, 2, 1))
|
||||
add(timed(firstOfMonth.plus(7, DateTimeUnit.DAY), 11, 1, 3))
|
||||
add(timed(firstOfMonth.plus(15, DateTimeUnit.DAY), 10, 1, 0))
|
||||
add(timed(firstOfMonth.plus(18, DateTimeUnit.DAY), 16, 1, 2))
|
||||
add(timed(firstOfMonth.plus(22, DateTimeUnit.DAY), 8, 2, 1))
|
||||
add(timed(firstOfMonth.plus(25, DateTimeUnit.DAY), 13, 1, 3))
|
||||
// Today, so the split pane's list and the grid's dots both have content.
|
||||
add(timed(today, 9, 1, 0))
|
||||
add(timed(today, 12, 1, 1))
|
||||
add(timed(today, 15, 2, 3))
|
||||
}
|
||||
}
|
||||
|
||||
private val SAMPLE_TITLES = listOf(
|
||||
"Standup",
|
||||
"Lunch",
|
||||
"Review",
|
||||
"Gym",
|
||||
"Call",
|
||||
"Workshop",
|
||||
"Dentist",
|
||||
"Trip",
|
||||
)
|
||||
@@ -2,7 +2,9 @@ package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
|
||||
/**
|
||||
@@ -37,12 +39,85 @@ data class MonthWeek(
|
||||
val countByDay: Map<LocalDate, Int>,
|
||||
)
|
||||
|
||||
/**
|
||||
* The events occupying each lane of [day] — column [col] of this week — in lane
|
||||
* order, capped at [laneCap] lanes.
|
||||
*
|
||||
* This is the same seating [spans]/[timedByDay] get when the paged grid draws a
|
||||
* week: bars keep the lane the row layout gave them, and the day's timed events
|
||||
* fill whatever slots are left, top-most first. Reading it here means the split
|
||||
* style's dots and the paged style's bars describe a day in the *same order*, so
|
||||
* dot _i_ and lane _i_ are the same event and one can morph into the other (#53).
|
||||
*
|
||||
* Deriving the dots independently — by distinct colour, as they first were —
|
||||
* left dot _i_ standing for no particular event, and quietly merged two events
|
||||
* that shared a calendar into a single dot.
|
||||
*/
|
||||
fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
|
||||
val byLane = arrayOfNulls<EventInstance>(laneCap)
|
||||
spans.forEach { span ->
|
||||
if (span.lane < laneCap && col in span.startCol..span.endCol) {
|
||||
byLane[span.lane] = span.event
|
||||
}
|
||||
}
|
||||
val free = (0 until laneCap).filter { byLane[it] == null }
|
||||
timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event ->
|
||||
byLane[free[i]] = event
|
||||
}
|
||||
return byLane.filterNotNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the continuous style (#38): a vertical stream of *self-contained*
|
||||
* months rather than one undifferentiated run of weeks. Each month is keyed by
|
||||
* absolute month index and carries only its own days — the boundary week keeps
|
||||
* its seven columns so the grid geometry never shifts, but the neighbour month's
|
||||
* dates are clipped out of it (see `clipWeekToMonth`) and render as blanks.
|
||||
*
|
||||
* The same loaded window is served two ways, because the Dense style is the same
|
||||
* scroll with the seams taken out and it would be wasteful to query the provider
|
||||
* twice for one range:
|
||||
*
|
||||
* - [monthsByIndex] — the block layout, keyed by absolute month index, each week
|
||||
* clipped to its own month.
|
||||
* - [weeksByIndex] — the flowing layout, keyed by absolute week index, boundary
|
||||
* weeks left carrying both months.
|
||||
*
|
||||
* Both hold only the loaded window; indices outside it render as placeholders
|
||||
* until the window catches up.
|
||||
*/
|
||||
sealed interface ContinuousMonthUiState {
|
||||
data object Loading : ContinuousMonthUiState
|
||||
data class Failure(val reason: FailureReason) : ContinuousMonthUiState
|
||||
data class Success(
|
||||
val today: LocalDate,
|
||||
val monthsByIndex: Map<Int, List<MonthWeek>>,
|
||||
val weeksByIndex: Map<Int, MonthWeek>,
|
||||
val weekStart: DayOfWeek,
|
||||
) : ContinuousMonthUiState
|
||||
}
|
||||
|
||||
sealed interface MonthUiState {
|
||||
data object Loading : MonthUiState
|
||||
data class Failure(val reason: FailureReason) : MonthUiState
|
||||
|
||||
/**
|
||||
* [weeks] is what the grid draws; [instancesByDay] is the same events keyed by
|
||||
* date and *uncapped*, so the split style's day pane can list everything on a
|
||||
* date without a second provider query — the month grid range already covers
|
||||
* it. All-day events sort first, then by start.
|
||||
*/
|
||||
data class Success(
|
||||
val month: YearMonth,
|
||||
val today: LocalDate,
|
||||
val weeks: List<MonthWeek>,
|
||||
val instancesByDay: Map<LocalDate, List<EventInstance>> = emptyMap(),
|
||||
/**
|
||||
* Travels on the state rather than being read at the call site, for the
|
||||
* same reason the agenda does it: a file-level constant would be fixed
|
||||
* for the process lifetime and drift from the zone the events were laid
|
||||
* out in after a device time-zone change.
|
||||
*/
|
||||
val zone: TimeZone = TimeZone.currentSystemDefault(),
|
||||
) : MonthUiState
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
@@ -20,21 +20,23 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.Month
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.util.Locale
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
@@ -48,16 +50,9 @@ class MonthViewModel @Inject constructor(
|
||||
) : ViewModel() {
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
private val locale: Locale = Locale.getDefault()
|
||||
|
||||
/** First day of the week, from the Settings preference (AUTO → locale). */
|
||||
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.weekStart
|
||||
.map { it.resolveFirstDay(locale) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = DayOfWeek.MONDAY,
|
||||
)
|
||||
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||
|
||||
/** Whether to fade events that have already finished (display concern only). */
|
||||
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||
@@ -75,6 +70,14 @@ class MonthViewModel @Inject constructor(
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
/** How the grid is laid out and navigated (#38, #53). */
|
||||
val viewStyle: StateFlow<MonthViewStyle> = settingsPrefs.monthViewStyle
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = MonthViewStyle.Paged,
|
||||
)
|
||||
|
||||
private val todayDate: LocalDate
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
@@ -82,8 +85,12 @@ class MonthViewModel @Inject constructor(
|
||||
val month: StateFlow<YearMonth> = _month
|
||||
|
||||
val state: StateFlow<MonthUiState> =
|
||||
combine(_month, weekStart) { ym, ws -> ym to ws }
|
||||
.flatMapLatest { (ym, ws) ->
|
||||
combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(ym, ws, style) }
|
||||
.flatMapLatest { (ym, ws, style) ->
|
||||
// The mirror of the gate below: the paged grid is what Paged and
|
||||
// Split draw, so under a scrolling style this query is a month
|
||||
// of provider work for a view that isn't on screen.
|
||||
if (style.isScrolling) return@flatMapLatest flowOf(MonthUiState.Loading)
|
||||
val range = monthGridRange(ym, ws, zone)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
@@ -100,21 +107,202 @@ class MonthViewModel @Inject constructor(
|
||||
initialValue = MonthUiState.Loading,
|
||||
)
|
||||
|
||||
// --- Continuous + Dense styles (#38) ----------------------------------
|
||||
//
|
||||
// These scroll through every month there is, so they can't load "a month" —
|
||||
// they load a sliding window of month indices around whatever is on screen.
|
||||
// The window only moves when the visible range comes within WINDOW_EDGE
|
||||
// months of a loaded edge, so a scroll re-queries occasionally rather than
|
||||
// on every frame.
|
||||
//
|
||||
// The window also *starts small and grows*. A provider Instances query
|
||||
// expands recurrences across its whole range, so opening straight onto a
|
||||
// year of months made the first frame wait for eleven months of expansion
|
||||
// when only one was about to be looked at. It now opens on INITIAL_PAD
|
||||
// months, and each completed load widens by GROWTH_STEP in both directions
|
||||
// until MAX_PAD — the months you can reach by scrolling arrive while you're
|
||||
// still looking at the first one.
|
||||
|
||||
/** How far around the visible range we currently load; grows as loads land. */
|
||||
@Volatile
|
||||
private var loadPad = INITIAL_PAD
|
||||
|
||||
/** Last reported visible range, so a widening step can re-centre on it. */
|
||||
@Volatile
|
||||
private var visibleMonths = monthIndexOf(YearMonth(todayDate.year, todayDate.month))
|
||||
.let { it..it }
|
||||
|
||||
private val _loadedMonths = MutableStateFlow(
|
||||
monthIndexOf(YearMonth(todayDate.year, todayDate.month))
|
||||
.let { clampMonthWindow(it - INITIAL_PAD..it + INITIAL_PAD) },
|
||||
)
|
||||
|
||||
val continuousState: StateFlow<ContinuousMonthUiState> =
|
||||
combine(_loadedMonths, weekStart, viewStyle) { window, ws, style ->
|
||||
Triple(window, ws, style)
|
||||
}
|
||||
.flatMapLatest { (window, ws, style) ->
|
||||
// Nothing to load for the paged and split styles — they have
|
||||
// their own single-month flow, and querying a year of months
|
||||
// behind them is pure waste.
|
||||
if (!style.isScrolling) return@flatMapLatest flowOf(ContinuousMonthUiState.Loading)
|
||||
// Widened to whole grid weeks at both ends: a month block still
|
||||
// has to know about an event that starts in the boundary week's
|
||||
// clipped-off days, or a bar running into the block would vanish.
|
||||
val first = firstOfMonth(yearMonthForIndex(window.first)).startOfGridWeek(ws)
|
||||
val last = firstOfMonth(yearMonthForIndex(window.last))
|
||||
.plus(1, DateTimeUnit.MONTH)
|
||||
.minus(1, DateTimeUnit.DAY)
|
||||
.startOfGridWeek(ws)
|
||||
.plus(6, DateTimeUnit.DAY)
|
||||
val range = first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
) { calendars, instances ->
|
||||
buildContinuousState(window, ws, style, calendars, instances)
|
||||
}
|
||||
}
|
||||
// A load landing is the cue to reach further out. Widening from here
|
||||
// rather than on a timer means each step waits for the previous one,
|
||||
// so the ladder can never outrun the provider.
|
||||
.onEach { if (it is ContinuousMonthUiState.Success) widenLoadedWindow() }
|
||||
.catch { emit(ContinuousMonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = ContinuousMonthUiState.Loading,
|
||||
)
|
||||
|
||||
/**
|
||||
* Report which absolute month indices are on screen. Cheap to call on every
|
||||
* scroll frame: it returns immediately unless the visible range has drifted
|
||||
* close enough to a loaded edge to warrant a wider query.
|
||||
*/
|
||||
fun onVisibleMonthsChanged(firstIndex: Int, lastIndex: Int) {
|
||||
visibleMonths = firstIndex..lastIndex
|
||||
nextLoadWindow(_loadedMonths.value, firstIndex, lastIndex, loadPad)
|
||||
?.let { _loadedMonths.value = clampMonthWindow(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* One rung up the ladder: reach [GROWTH_STEP] further in each direction,
|
||||
* stopping at [MAX_PAD]. A no-op once there, so the provider notifications
|
||||
* that re-emit the same window don't restart it.
|
||||
*/
|
||||
private fun widenLoadedWindow() {
|
||||
if (loadPad >= MAX_PAD) return
|
||||
loadPad = (loadPad + GROWTH_STEP).coerceAtMost(MAX_PAD)
|
||||
_loadedMonths.value = clampMonthWindow(
|
||||
visibleMonths.first - loadPad..visibleMonths.last + loadPad,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildContinuousState(
|
||||
window: IntRange,
|
||||
weekStart: DayOfWeek,
|
||||
style: MonthViewStyle,
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): ContinuousMonthUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
// Bucketed once for the whole window: a row then takes the handful of
|
||||
// events on its seven days instead of re-scanning a year of them.
|
||||
val byDay = DayIndex(instances, zone)
|
||||
// Only the style on screen is laid out. Building both doubled the work
|
||||
// for a layout nothing was going to draw.
|
||||
val months = if (style == MonthViewStyle.Continuous) {
|
||||
window.associateWith { index ->
|
||||
val ym = yearMonthForIndex(index)
|
||||
layoutMonthWeeks(ym, weekStart, byDay, zone).map { clipWeekToMonth(it, ym) }
|
||||
}
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
val weeks = if (style == MonthViewStyle.Dense) {
|
||||
weekWindowFor(window, weekStart).associateWith { index ->
|
||||
val days = (0 until 7).map {
|
||||
weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY)
|
||||
}
|
||||
layoutCalendarWeek(days, byDay.eventsOn(days), zone)
|
||||
}
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
return ContinuousMonthUiState.Success(
|
||||
today = todayDate,
|
||||
monthsByIndex = months,
|
||||
weeksByIndex = weeks,
|
||||
weekStart = weekStart,
|
||||
)
|
||||
}
|
||||
|
||||
// --- Split style (#53) ------------------------------------------------
|
||||
//
|
||||
// The first selected-day concept in the app: the other views drill straight
|
||||
// into a date, while the split style keeps one selected and lists it below
|
||||
// the grid.
|
||||
|
||||
private val _selectedDate = MutableStateFlow(todayDate)
|
||||
val selectedDate: StateFlow<LocalDate> = _selectedDate
|
||||
|
||||
/**
|
||||
* Select [date], following it to its month if it sits in the grid's leading
|
||||
* or trailing days — tapping a greyed-out day should show that day, not
|
||||
* silently list a date the grid isn't pointing at.
|
||||
*/
|
||||
fun selectDate(date: LocalDate) {
|
||||
_selectedDate.value = date
|
||||
val target = YearMonth(date.year, date.month)
|
||||
if (target != _month.value) _month.value = target
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the selection with the month: today if the new month holds it, else
|
||||
* its 1st. Leaving the old date selected would list a day the grid no longer
|
||||
* shows.
|
||||
*/
|
||||
private fun realignSelection() {
|
||||
_selectedDate.value = selectionForMonth(_month.value, todayDate)
|
||||
}
|
||||
|
||||
fun goToPrev() {
|
||||
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
||||
realignSelection()
|
||||
}
|
||||
|
||||
fun goToNext() {
|
||||
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
|
||||
realignSelection()
|
||||
}
|
||||
|
||||
fun goToToday() {
|
||||
_month.value = YearMonth(todayDate.year, todayDate.month)
|
||||
_selectedDate.value = todayDate
|
||||
}
|
||||
|
||||
/**
|
||||
* Track [_month] to the month a scrolling style is showing. Those styles
|
||||
* navigate by scroll position and never set [_month] themselves, so it would
|
||||
* otherwise sit wherever paged navigation last left it (today's month, on a
|
||||
* fresh open) — and a switch to a paged style, or a reseed of the other
|
||||
* scrolling style's list, would jump there instead of holding position. The
|
||||
* selection is realigned alongside it so that landing on the Split style
|
||||
* shows a live day in the month on screen rather than a stale, off-month one.
|
||||
*/
|
||||
fun syncScrollMonth(ym: YearMonth) {
|
||||
if (_month.value == ym) return
|
||||
_month.value = ym
|
||||
_selectedDate.value = selectionForMonth(ym, todayDate)
|
||||
}
|
||||
|
||||
/** Jump to the month containing [date] (drawer jump-to-date). */
|
||||
fun goToDate(date: LocalDate) {
|
||||
_month.value = YearMonth(date.year, date.month)
|
||||
_selectedDate.value = date
|
||||
}
|
||||
|
||||
private fun buildState(
|
||||
@@ -126,10 +314,13 @@ class MonthViewModel @Inject constructor(
|
||||
if (calendars.isEmpty()) {
|
||||
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
val weeks = layoutMonthWeeks(ym, weekStart, instances, zone)
|
||||
return MonthUiState.Success(
|
||||
month = ym,
|
||||
today = todayDate,
|
||||
weeks = layoutMonthWeeks(ym, weekStart, instances, zone),
|
||||
weeks = weeks,
|
||||
instancesByDay = instancesByDay(weeks.flatMap { it.days }, instances, zone),
|
||||
zone = zone,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -148,16 +339,83 @@ internal fun layoutMonthWeeks(
|
||||
weekStart: DayOfWeek,
|
||||
instances: List<EventInstance>,
|
||||
zone: TimeZone,
|
||||
): List<MonthWeek> = layoutMonthWeeks(ym, weekStart, DayIndex(instances, zone), zone)
|
||||
|
||||
/**
|
||||
* As above, but reusing a [DayIndex] already built for a wider range — what the
|
||||
* scrolling styles need, since they lay out many months from one query and
|
||||
* rebuilding the index per month would put the cost straight back.
|
||||
*/
|
||||
internal fun layoutMonthWeeks(
|
||||
ym: YearMonth,
|
||||
weekStart: DayOfWeek,
|
||||
byDay: DayIndex,
|
||||
zone: TimeZone,
|
||||
): List<MonthWeek> {
|
||||
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
|
||||
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
|
||||
val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||
val daysInMonth =
|
||||
java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
|
||||
val weekCount = (leadOffset + daysInMonth + 6) / 7
|
||||
val gridStart = firstOfMonth(ym).startOfGridWeek(weekStart)
|
||||
val weekCount = weekRowsInMonth(ym, weekStart)
|
||||
|
||||
return (0 until weekCount).map { row ->
|
||||
val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
|
||||
layoutCalendarWeek(days, byDay.eventsOn(days), zone)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Events bucketed by the dates they cover, built once per load.
|
||||
*
|
||||
* [layoutCalendarWeek] opens by filtering the instances it was handed down to
|
||||
* the ones touching its seven days. That is fine for a single month's grid, but
|
||||
* the scrolling styles lay out a hundred-odd rows from one query, and scanning
|
||||
* *every* instance in an eleven-month window for each of them made the work grow
|
||||
* with the number of enabled calendars until opening the view stalled.
|
||||
*
|
||||
* Membership is decided by [coversDay] itself rather than by re-deriving the
|
||||
* rule from the timestamps: the all-day and timed cases have enough edge cases
|
||||
* between them (UTC anchoring, exclusive ends, zero-length events at midnight)
|
||||
* that a second implementation would drift. The walk only visits an event's own
|
||||
* candidate days, so the check runs a couple of times per event rather than once
|
||||
* per event per row.
|
||||
*/
|
||||
internal class DayIndex(instances: List<EventInstance>, private val zone: TimeZone) {
|
||||
|
||||
private val byDay: Map<LocalDate, List<EventInstance>> = buildMap<LocalDate, MutableList<EventInstance>> {
|
||||
instances.forEach { event ->
|
||||
// All-day events are anchored to UTC midnights; timed ones to the
|
||||
// device zone. Either way the candidate span is the event's own
|
||||
// dates, which is one or two days for almost everything.
|
||||
val anchor = if (event.isAllDay) TimeZone.UTC else zone
|
||||
var day = event.start.toLocalDateTime(anchor).date
|
||||
val lastCandidate = event.end.toLocalDateTime(anchor).date
|
||||
while (day <= lastCandidate) {
|
||||
if (event.coversDay(day, zone)) getOrPut(day) { mutableListOf() }.add(event)
|
||||
day = day.plus(1, DateTimeUnit.DAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Every event touching any of [days], each listed once, in input order. */
|
||||
fun eventsOn(days: List<LocalDate>): List<EventInstance> {
|
||||
if (days.isEmpty()) return emptyList()
|
||||
val hits = days.flatMap { byDay[it].orEmpty() }
|
||||
// A multi-day event is bucketed on each date it covers, so a row that
|
||||
// holds several of its days would otherwise list it several times.
|
||||
return if (hits.size < 2) hits else hits.distinctBy { it.instanceId }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one week row's events for rendering. Split out of [layoutMonthWeeks] so
|
||||
* the continuous style — which streams weeks rather than months and so has no
|
||||
* enclosing [YearMonth] to slice by — lays each row out identically.
|
||||
*
|
||||
* [days] must be the row's seven consecutive dates, in display order.
|
||||
*/
|
||||
internal fun layoutCalendarWeek(
|
||||
days: List<LocalDate>,
|
||||
instances: List<EventInstance>,
|
||||
zone: TimeZone,
|
||||
): MonthWeek {
|
||||
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
|
||||
val (bars, singles) = weekEvents.partition { ev ->
|
||||
ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
|
||||
@@ -172,7 +430,7 @@ internal fun layoutMonthWeeks(
|
||||
continuesRight = s.event.coversDay(days.last().plus(1, DateTimeUnit.DAY), zone),
|
||||
)
|
||||
}
|
||||
MonthWeek(
|
||||
return MonthWeek(
|
||||
days = days,
|
||||
spans = spans,
|
||||
timedByDay = days.associateWith { d ->
|
||||
@@ -181,6 +439,22 @@ internal fun layoutMonthWeeks(
|
||||
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every event touching each of [days], all-day first then by start time. Unlike
|
||||
* [MonthWeek.timedByDay] this keeps multi-day and all-day events on every date
|
||||
* they cover and applies no display cap, so the split style's day pane can list a
|
||||
* date in full without querying the provider again.
|
||||
*/
|
||||
internal fun instancesByDay(
|
||||
days: List<LocalDate>,
|
||||
instances: List<EventInstance>,
|
||||
zone: TimeZone,
|
||||
): Map<LocalDate, List<EventInstance>> =
|
||||
days.associateWith { day ->
|
||||
instances
|
||||
.filter { it.coversDay(day, zone) }
|
||||
.sortedWith(compareByDescending<EventInstance> { it.isAllDay }.thenBy { it.start })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,6 +474,198 @@ internal fun monthGridRange(
|
||||
return start..end
|
||||
}
|
||||
|
||||
/**
|
||||
* The sliding window's shape.
|
||||
*
|
||||
* [INITIAL_PAD] is what the first frame waits for — one month either side of the
|
||||
* visible one, so opening the view costs about what the paged style costs. Each
|
||||
* completed load then reaches [GROWTH_STEP] further out until [MAX_PAD], filling
|
||||
* in the months a scroll could reach while the first ones are already on screen.
|
||||
*
|
||||
* [WINDOW_EDGE] is how close the visible range may drift to a loaded edge before
|
||||
* it reloads. It is always kept below the current pad — a trigger at or beyond
|
||||
* the pad would re-fire the moment its own reload landed.
|
||||
*/
|
||||
private const val INITIAL_PAD = 1
|
||||
private const val GROWTH_STEP = 2
|
||||
private const val MAX_PAD = 5
|
||||
private const val WINDOW_EDGE = 2
|
||||
|
||||
/** The reload trigger for a given pad, held strictly inside it. */
|
||||
internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLeast(0)
|
||||
|
||||
/**
|
||||
* Which day the split style should select when the grid lands on [month]:
|
||||
* [today] when the month holds it, otherwise the 1st. Pure so the rule can be
|
||||
* tested without standing up a view model and a provider behind it.
|
||||
*/
|
||||
internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate =
|
||||
if (YearMonth(today.year, today.month) == month) {
|
||||
today
|
||||
} else {
|
||||
LocalDate(month.year, month.month, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* The window to load for a visible range, or null to keep the current one.
|
||||
*
|
||||
* Kept pure and separate from the view model so the hysteresis — the reason a
|
||||
* scroll doesn't re-query the provider on every frame — is testable on its own.
|
||||
*/
|
||||
internal fun nextLoadWindow(
|
||||
loaded: IntRange,
|
||||
firstVisible: Int,
|
||||
lastVisible: Int,
|
||||
pad: Int = MAX_PAD,
|
||||
): IntRange? {
|
||||
val edge = edgeForPad(pad)
|
||||
val comfortablyInside =
|
||||
firstVisible - edge >= loaded.first && lastVisible + edge <= loaded.last
|
||||
if (comfortablyInside) return null
|
||||
return (firstVisible - pad)..(lastVisible + pad)
|
||||
}
|
||||
|
||||
/**
|
||||
* The continuous grid addresses months by an absolute index, so the list has one
|
||||
* stable, gap-free coordinate space to scroll through and key its items by.
|
||||
* Index 0 is January 1900; the list runs to [continuousMonthCount].
|
||||
*
|
||||
* Unlike the week indexing this replaced, the coordinate space doesn't depend on
|
||||
* the week-start preference — changing it reflows the rows *inside* a month
|
||||
* block but never moves the block, so a scroll position stays put.
|
||||
*/
|
||||
private const val MONTH_INDEX_EPOCH_YEAR = 1900
|
||||
private const val MONTH_INDEX_END_YEAR = 2100
|
||||
|
||||
internal fun monthIndexOf(ym: YearMonth): Int =
|
||||
(ym.year - MONTH_INDEX_EPOCH_YEAR) * 12 + ym.month.ordinal
|
||||
|
||||
internal fun yearMonthForIndex(index: Int): YearMonth = YearMonth(
|
||||
MONTH_INDEX_EPOCH_YEAR + index / 12,
|
||||
Month.entries[index % 12],
|
||||
)
|
||||
|
||||
/** Total months the continuous grid scrolls through (1900 → 2100). */
|
||||
internal fun continuousMonthCount(): Int =
|
||||
(MONTH_INDEX_END_YEAR - MONTH_INDEX_EPOCH_YEAR + 1) * 12
|
||||
|
||||
/**
|
||||
* Each month occupies two LazyColumn items — its sticky header and the block of
|
||||
* week rows under it — so the two coordinate spaces differ by a factor of two.
|
||||
* Scrolling to a month means scrolling to its header.
|
||||
*/
|
||||
internal fun itemIndexForMonth(monthIndex: Int): Int = monthIndex * 2
|
||||
|
||||
internal fun monthIndexForItem(itemIndex: Int): Int = itemIndex / 2
|
||||
|
||||
/**
|
||||
* Hold a load window inside the months that actually exist. The pad
|
||||
* [nextLoadWindow] adds can otherwise push an index past either end of the
|
||||
* 1900–2100 span, and [yearMonthForIndex] has no month to hand back for one.
|
||||
*/
|
||||
internal fun clampMonthWindow(window: IntRange): IntRange {
|
||||
val last = continuousMonthCount() - 1
|
||||
return window.first.coerceIn(0, last)..window.last.coerceIn(0, last)
|
||||
}
|
||||
|
||||
internal fun firstOfMonth(ym: YearMonth): LocalDate = LocalDate(ym.year, ym.month, 1)
|
||||
|
||||
/**
|
||||
* The Dense style addresses *weeks* by absolute index instead: it has no month
|
||||
* blocks to key by, and a row's identity has to survive the months around it
|
||||
* scrolling past. Index 0 is the first week of 1900 under the active week-start,
|
||||
* so — like the month space — every index is non-negative and the LazyColumn's
|
||||
* item indices and week indices are the same number.
|
||||
*
|
||||
* Unlike month indices these *do* shift with the week-start preference, which is
|
||||
* why the list state is keyed on it.
|
||||
*/
|
||||
private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1)
|
||||
private val WEEK_INDEX_END = LocalDate(2100, 12, 31)
|
||||
|
||||
internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
|
||||
val base = WEEK_INDEX_EPOCH.startOfGridWeek(weekStart)
|
||||
return base.daysUntil(date.startOfGridWeek(weekStart)) / 7
|
||||
}
|
||||
|
||||
internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate =
|
||||
WEEK_INDEX_EPOCH.startOfGridWeek(weekStart).plus(index * 7, DateTimeUnit.DAY)
|
||||
|
||||
/** Total weeks the Dense grid scrolls through (1900 → 2100). */
|
||||
internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
|
||||
weekIndexOf(WEEK_INDEX_END, weekStart) + 1
|
||||
|
||||
/** Which month a Dense row belongs to, for reporting the visible range. */
|
||||
internal fun monthIndexForWeek(weekIndex: Int, weekStart: DayOfWeek): Int {
|
||||
// The row's midweek day: a boundary week is reported as whichever month owns
|
||||
// most of it, the same rule the title uses.
|
||||
val midweek = weekStartForIndex(weekIndex, weekStart).plus(3, DateTimeUnit.DAY)
|
||||
return monthIndexOf(YearMonth(midweek.year, midweek.month))
|
||||
}
|
||||
|
||||
/**
|
||||
* Every week index the months in [window] touch — the Dense rows one month-window
|
||||
* load covers. Widened by a week at each end so the boundary rows either side are
|
||||
* laid out too, rather than flickering as placeholders.
|
||||
*/
|
||||
internal fun weekWindowFor(window: IntRange, weekStart: DayOfWeek): IntRange {
|
||||
val first = weekIndexOf(firstOfMonth(yearMonthForIndex(window.first)), weekStart) - 1
|
||||
val lastMonthEnd = firstOfMonth(yearMonthForIndex(window.last))
|
||||
.plus(1, DateTimeUnit.MONTH)
|
||||
.minus(1, DateTimeUnit.DAY)
|
||||
return first..(weekIndexOf(lastMonthEnd, weekStart) + 1)
|
||||
}
|
||||
|
||||
/** How many week rows [ym] needs under [weekStart] — 4 (a non-leap February), 5 or 6. */
|
||||
internal fun weekRowsInMonth(ym: YearMonth, weekStart: DayOfWeek): Int {
|
||||
val first = firstOfMonth(ym)
|
||||
val leadOffset = ((first.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||
val daysInMonth = java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
|
||||
return (leadOffset + daysInMonth + 6) / 7
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip everything outside [month] from a boundary week row, for the continuous
|
||||
* style's self-contained month blocks.
|
||||
*
|
||||
* The seven [MonthWeek.days] stay put — the row keeps its column geometry, and
|
||||
* the grid decides which cells to draw blank — but the neighbour month's events
|
||||
* are removed: its pills and counts go, and a bar reaching in from (or out into)
|
||||
* it is cut back to the month's own columns. A bar cut this way keeps a flat
|
||||
* cap, which is what says "this continues past the block" rather than "it ends
|
||||
* here".
|
||||
*/
|
||||
internal fun clipWeekToMonth(week: MonthWeek, month: YearMonth): MonthWeek {
|
||||
val inMonth = week.days.map { it.year == month.year && it.month == month.month }
|
||||
val firstCol = inMonth.indexOfFirst { it }
|
||||
val lastCol = inMonth.indexOfLast { it }
|
||||
// Can't happen for a row of the month's own grid, but a caller-proof no-op
|
||||
// beats an out-of-range crash.
|
||||
if (firstCol == -1) {
|
||||
return week.copy(spans = emptyList(), timedByDay = emptyMap(), countByDay = emptyMap())
|
||||
}
|
||||
val spans = week.spans.mapNotNull { span ->
|
||||
val start = maxOf(span.startCol, firstCol)
|
||||
val end = minOf(span.endCol, lastCol)
|
||||
if (start > end) {
|
||||
null
|
||||
} else {
|
||||
span.copy(
|
||||
startCol = start,
|
||||
endCol = end,
|
||||
continuesLeft = span.continuesLeft || start > span.startCol,
|
||||
continuesRight = span.continuesRight || end < span.endCol,
|
||||
)
|
||||
}
|
||||
}
|
||||
val own = week.days.filterIndexed { col, _ -> inMonth[col] }
|
||||
return week.copy(
|
||||
spans = spans,
|
||||
timedByDay = week.timedByDay.filterKeys { it in own },
|
||||
countByDay = week.countByDay.filterKeys { it in own },
|
||||
)
|
||||
}
|
||||
|
||||
internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate {
|
||||
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
||||
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/**
|
||||
* How the Month view lays itself out (#38, #53).
|
||||
*
|
||||
* One setting rather than two independent toggles: "vertical scrolling" and
|
||||
* "month plus agenda" would otherwise multiply into four combinations to build
|
||||
* and test, most of which nobody asked for.
|
||||
*
|
||||
* [Split] does **not** replace or disable the Agenda view — that stays a forward
|
||||
* multi-day window with its own range model, while the split pane lists a single
|
||||
* selected day.
|
||||
*/
|
||||
enum class MonthViewStyle {
|
||||
/** Month pages, swiped left/right. Full event bars and pills per day. */
|
||||
Paged,
|
||||
|
||||
/**
|
||||
* Months stacked into one vertical scroll, each a self-contained block under
|
||||
* its own sticky header: a block shows only its own days, and whitespace
|
||||
* separates it from the next (#38).
|
||||
*/
|
||||
Continuous,
|
||||
|
||||
/**
|
||||
* [Continuous] with the seams taken out: one uninterrupted stream of weeks
|
||||
* where months flow into each other. Each date appears exactly once — paging
|
||||
* repeats a boundary week at both ends — and the 1st names its month, the
|
||||
* only marker of where one ends.
|
||||
*/
|
||||
Dense,
|
||||
|
||||
/** Compact dots-only grid over a list of the selected day's events (#53). */
|
||||
Split,
|
||||
}
|
||||
|
||||
/** Both vertically scrolling styles, which share a data window and a list state. */
|
||||
val MonthViewStyle.isScrolling: Boolean
|
||||
get() = this == MonthViewStyle.Continuous || this == MonthViewStyle.Dense
|
||||
|
||||
@get:StringRes
|
||||
val MonthViewStyle.labelRes: Int
|
||||
get() = when (this) {
|
||||
MonthViewStyle.Paged -> R.string.month_style_paged
|
||||
MonthViewStyle.Continuous -> R.string.month_style_continuous
|
||||
MonthViewStyle.Dense -> R.string.month_style_dense
|
||||
MonthViewStyle.Split -> R.string.month_style_split
|
||||
}
|
||||
|
||||
@get:StringRes
|
||||
val MonthViewStyle.descriptionRes: Int
|
||||
get() = when (this) {
|
||||
MonthViewStyle.Paged -> R.string.month_style_paged_summary
|
||||
MonthViewStyle.Continuous -> R.string.month_style_continuous_summary
|
||||
MonthViewStyle.Dense -> R.string.month_style_dense_summary
|
||||
MonthViewStyle.Split -> R.string.month_style_split_summary
|
||||
}
|
||||
@@ -54,7 +54,7 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.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.month.MonthStylePreview
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
||||
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
|
||||
/**
|
||||
* The Month view style chooser (#38, #53).
|
||||
*
|
||||
* A live, scaled-down Month view sits above the options and changes as you pick
|
||||
* one, so the choice is made by looking rather than by reading "Continuous" and
|
||||
* guessing. Selecting therefore applies immediately and leaves the picker open —
|
||||
* closing on tap would hide the very thing the screen is for. Back exits, the
|
||||
* same as the App name picker, which stays open for the same reason.
|
||||
*
|
||||
* Under the preview sits the selected style's own one-line blurb, then the
|
||||
* family's standard picker shape: connected single-line grouped rows with a
|
||||
* tonal highlight and [SelectedCheck] on the current one.
|
||||
*/
|
||||
@Composable
|
||||
internal fun MonthViewStylePicker(
|
||||
selected: MonthViewStyle,
|
||||
weekStart: DayOfWeek,
|
||||
onSelect: (MonthViewStyle) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val options = MonthViewStyle.entries
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.settings_month_view_style),
|
||||
onDismiss = onDismiss,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The selected style's own blurb lives here, under its preview, rather
|
||||
// than on every row — the rows stay single-line and dense, and the words
|
||||
// describe exactly what is being shown above.
|
||||
PickerDescription(stringResource(selected.descriptionRes))
|
||||
options.forEachIndexed { index, style ->
|
||||
val isSelected = style == selected
|
||||
GroupedRow(
|
||||
title = stringResource(style.labelRes),
|
||||
position = positionOf(index, options.size),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
// Applies straight away; the preview above is the confirmation,
|
||||
// so there is nothing to dismiss for.
|
||||
onClick = { onSelect(style) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val PREVIEW_HEIGHT = 280.dp
|
||||
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||
@@ -23,6 +23,8 @@ import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -42,8 +44,8 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Cake
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.DragHandle
|
||||
import androidx.compose.material.icons.filled.SwapVert
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
@@ -75,6 +77,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.colorResource
|
||||
@@ -93,6 +96,7 @@ import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.appname.LauncherName
|
||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
@@ -100,6 +104,7 @@ import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||
@@ -121,6 +126,7 @@ import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
||||
import de.jeanlucmakiola.floret.components.ReorderableColumn
|
||||
import de.jeanlucmakiola.floret.components.ReorderableRowHeight
|
||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||
@@ -131,11 +137,12 @@ import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||
import de.jeanlucmakiola.calendula.ui.theme.BundledFont
|
||||
@@ -487,8 +494,10 @@ private fun AppearanceScreen(
|
||||
var showPastEvents by remember { mutableStateOf(false) }
|
||||
var showBrandFont by remember { mutableStateOf(false) }
|
||||
var showPlainFont by remember { mutableStateOf(false) }
|
||||
var showAppName by remember { mutableStateOf(false) }
|
||||
|
||||
val fonts by viewModel.fontState.collectAsStateWithLifecycle()
|
||||
val launcherName by viewModel.launcherName.collectAsStateWithLifecycle()
|
||||
// A picked file that didn't parse as a font: tell the user and keep the old choice.
|
||||
val context = LocalContext.current
|
||||
val importFailedMessage = stringResource(R.string.settings_font_import_failed)
|
||||
@@ -571,6 +580,18 @@ private fun AppearanceScreen(
|
||||
position = Position.Top,
|
||||
onClick = { showDefaultView = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_today_toolbar),
|
||||
summary = stringResource(R.string.settings_today_toolbar_summary),
|
||||
position = Position.Middle,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.todayButtonInToolbar,
|
||||
onCheckedChange = viewModel::setTodayButtonInToolbar,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setTodayButtonInToolbar(!state.todayButtonInToolbar) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_week_start),
|
||||
summary = weekStartLabel(state.weekStart),
|
||||
@@ -667,8 +688,58 @@ private fun AppearanceScreen(
|
||||
},
|
||||
onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// App name — chooses the launcher label between "Calendula" and "Calendar"
|
||||
// (issue #44). Own group: it's a launcher/system concern, not calendar
|
||||
// formatting. A sub-page chooser (not a switch), matching the app's other
|
||||
// "choose one" settings and leaving room for more names later.
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_app_name),
|
||||
summary = launcherNameLabel(launcherName),
|
||||
position = Position.Alone,
|
||||
onClick = { showAppName = true },
|
||||
)
|
||||
}
|
||||
|
||||
if (showAppName) {
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.settings_app_name),
|
||||
onDismiss = { showAppName = false },
|
||||
predictiveBack = true,
|
||||
) {
|
||||
// Show both names as launcher-mark previews so the user sees what
|
||||
// they'd switch to, not just the current state. Tapping applies
|
||||
// immediately and highlights — the picker stays open so the change is
|
||||
// visible; back exits.
|
||||
Text(
|
||||
text = stringResource(R.string.settings_app_name_summary),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp),
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
LauncherName.entries.forEach { option ->
|
||||
AppNameOptionCard(
|
||||
name = option,
|
||||
selected = launcherName == option,
|
||||
onClick = { viewModel.setLauncherName(option) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showTheme) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_theme),
|
||||
@@ -778,12 +849,24 @@ private fun ViewsScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
var showMonthStyle by remember { mutableStateOf(false) }
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_views),
|
||||
onBack = onBack,
|
||||
) {
|
||||
val config = state.quickSwitchConfig
|
||||
|
||||
// Per-view layout, above the cross-view switcher/order settings below.
|
||||
SectionHeader(stringResource(R.string.settings_month_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_month_view_style),
|
||||
summary = stringResource(state.monthViewStyle.labelRes),
|
||||
position = Position.Alone,
|
||||
onClick = { showMonthStyle = true },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
SectionHeader(stringResource(R.string.settings_quick_switch_header))
|
||||
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -829,6 +912,16 @@ private fun ViewsScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showMonthStyle) {
|
||||
MonthViewStylePicker(
|
||||
selected = state.monthViewStyle,
|
||||
// The preview is a real grid, so it uses the real week start too.
|
||||
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
||||
onSelect = viewModel::setMonthViewStyle,
|
||||
onDismiss = { showMonthStyle = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One reorderable view row: the view's icon and name, an optional [trailing]
|
||||
@@ -1081,13 +1174,7 @@ private fun NotificationsScreen(
|
||||
},
|
||||
position = Position.Top,
|
||||
trailing = if (batteryExempt) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
@@ -1692,6 +1779,98 @@ private fun openUrl(context: Context, url: String) {
|
||||
runCatching { context.startActivity(intent) }
|
||||
}
|
||||
|
||||
/** The display name for a launcher-label choice (issue #44). */
|
||||
@Composable
|
||||
private fun launcherNameLabel(name: LauncherName): String = stringResource(
|
||||
when (name) {
|
||||
LauncherName.CALENDULA -> R.string.app_name
|
||||
LauncherName.CALENDAR -> R.string.app_name_calendar_alias
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* One selectable launcher-name preview in the App name picker (issue #44): the
|
||||
* app's launcher mark over the name, framed as a card. The active one carries a
|
||||
* primary border, a tinted container and a check; tapping selects it. The mark
|
||||
* is the same for both — only the label changes — so the card previews exactly
|
||||
* what the home screen will read.
|
||||
*/
|
||||
@Composable
|
||||
private fun AppNameOptionCard(
|
||||
name: LauncherName,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val shape = RoundedCornerShape(24.dp)
|
||||
val borderColor = if (selected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outlineVariant
|
||||
}
|
||||
val containerColor = if (selected) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(containerColor)
|
||||
.border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = shape)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 20.dp, horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// The adaptive launcher mark, reconstructed as a squircle (as in the
|
||||
// onboarding BrandHero) so it renders identically everywhere.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(colorResource(R.color.ic_launcher_background)),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = launcherNameLabel(name),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
)
|
||||
// Selection indicator: a filled check when active, an empty ring otherwise.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent)
|
||||
.then(
|
||||
if (selected) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
|
||||
},
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (selected) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun themeLabel(mode: ThemeMode): String = stringResource(
|
||||
when (mode) {
|
||||
@@ -1833,13 +2012,7 @@ private fun FontOptionRow(
|
||||
}
|
||||
},
|
||||
trailing = if (selected) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
|
||||
/**
|
||||
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||
@@ -37,6 +38,8 @@ data class SettingsUiState(
|
||||
val dimCompletedEvents: Boolean = false,
|
||||
/** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */
|
||||
val showWeekNumbers: Boolean = false,
|
||||
/** Whether the jump-to-today control sits in the top bar instead of the FAB (#60). */
|
||||
val todayButtonInToolbar: Boolean = false,
|
||||
/** 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). */
|
||||
@@ -49,6 +52,8 @@ data class SettingsUiState(
|
||||
val defaultView: CalendarView = CalendarView.Week,
|
||||
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
|
||||
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
|
||||
/** How the Month view lays itself out: pages, continuous scroll, or split (#38, #53). */
|
||||
val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged,
|
||||
/** Order of the views in the navigation drawer (#24); every view is always listed. */
|
||||
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||
|
||||
@@ -10,6 +10,8 @@ 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.appname.LauncherName
|
||||
import de.jeanlucmakiola.calendula.data.appname.LauncherNameManager
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
@@ -33,6 +35,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
||||
@@ -43,7 +46,9 @@ import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
@@ -62,6 +67,7 @@ class SettingsViewModel @Inject constructor(
|
||||
repository: CalendarRepository,
|
||||
private val specialDatesEngine: SpecialDatesSyncEngine,
|
||||
specialDatesSpec: SpecialDatesCalendarSpec,
|
||||
private val launcherNameManager: LauncherNameManager,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
@ApplicationContext private val appContext: Context,
|
||||
) : ViewModel() {
|
||||
@@ -124,8 +130,9 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.showWeekNumbers,
|
||||
prefs.agendaShowToday,
|
||||
prefs.softenCalendarColors,
|
||||
) { hourLines, weekNumbers, showToday, soften ->
|
||||
DisplayToggles(hourLines, weekNumbers, showToday, soften)
|
||||
prefs.todayButtonInToolbar,
|
||||
) { hourLines, weekNumbers, showToday, soften, todayInToolbar ->
|
||||
DisplayToggles(hourLines, weekNumbers, showToday, soften, todayInToolbar)
|
||||
},
|
||||
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
||||
ViewSettings(
|
||||
@@ -134,6 +141,7 @@ class SettingsViewModel @Inject constructor(
|
||||
showWeekNumbers = toggles.showWeekNumbers,
|
||||
agendaShowToday = toggles.agendaShowToday,
|
||||
softenColors = toggles.softenColors,
|
||||
todayButtonInToolbar = toggles.todayButtonInToolbar,
|
||||
)
|
||||
},
|
||||
combine(
|
||||
@@ -143,8 +151,12 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.dimCompletedEvents,
|
||||
// View customisation (#24) folded into one flow so it fits this
|
||||
// group — the outer combine is already at its five-arg limit.
|
||||
combine(prefs.quickSwitchConfig, prefs.drawerViewOrder) { quickSwitch, drawer ->
|
||||
ViewCustomization(quickSwitch, drawer)
|
||||
combine(
|
||||
prefs.quickSwitchConfig,
|
||||
prefs.drawerViewOrder,
|
||||
prefs.monthViewStyle,
|
||||
) { quickSwitch, drawer, monthStyle ->
|
||||
ViewCustomization(quickSwitch, drawer, monthStyle)
|
||||
},
|
||||
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
||||
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
||||
@@ -159,12 +171,14 @@ class SettingsViewModel @Inject constructor(
|
||||
showWeekNumbers = views.showWeekNumbers,
|
||||
agendaShowToday = views.agendaShowToday,
|
||||
softenColors = views.softenColors,
|
||||
todayButtonInToolbar = views.todayButtonInToolbar,
|
||||
agendaShowRangeBar = misc.showRangeBar,
|
||||
autofocusEventTitle = misc.autofocusEventTitle,
|
||||
pastEventDisplay = misc.pastEventDisplay,
|
||||
dimCompletedEvents = misc.dimCompletedEvents,
|
||||
quickSwitchConfig = misc.viewCustomization.quickSwitch,
|
||||
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
||||
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||
defaultReminderMinutes = defaults.defaultReminder,
|
||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||
@@ -199,6 +213,28 @@ class SettingsViewModel @Inject constructor(
|
||||
initialValue = AppFontSettings(),
|
||||
)
|
||||
|
||||
/**
|
||||
* The launcher-label choice (issue #44). Backed by the manifest aliases'
|
||||
* component-enabled state rather than a stored preference, so it's read
|
||||
* imperatively via [LauncherNameManager] and held here in its own flow — the
|
||||
* main settings combine is already at its arity limit and this isn't a
|
||||
* DataStore flow anyway.
|
||||
*
|
||||
* Seeded with the manifest default and corrected off-thread rather than read
|
||||
* in the initializer: `getComponentEnabledSetting` is a binder round-trip to
|
||||
* system_server, and this ViewModel is constructed on the main thread while
|
||||
* Settings is opening. The row it feeds is well below the fold, so the
|
||||
* one-frame correction is never visible.
|
||||
*/
|
||||
private val _launcherName = MutableStateFlow(LauncherName.CALENDULA)
|
||||
val launcherName: StateFlow<LauncherName> = _launcherName.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
_launcherName.value = withContext(io) { launcherNameManager.current() }
|
||||
}
|
||||
}
|
||||
|
||||
// Emitted when a picked font file couldn't be read as a font; the screen
|
||||
// surfaces it and the previous selection stays put.
|
||||
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
@@ -234,6 +270,7 @@ class SettingsViewModel @Inject constructor(
|
||||
val showWeekNumbers: Boolean,
|
||||
val agendaShowToday: Boolean,
|
||||
val softenColors: Boolean,
|
||||
val todayButtonInToolbar: Boolean,
|
||||
)
|
||||
|
||||
private data class DisplayToggles(
|
||||
@@ -241,6 +278,7 @@ class SettingsViewModel @Inject constructor(
|
||||
val showWeekNumbers: Boolean,
|
||||
val agendaShowToday: Boolean,
|
||||
val softenColors: Boolean,
|
||||
val todayButtonInToolbar: Boolean,
|
||||
)
|
||||
|
||||
private data class MiscSettings(
|
||||
@@ -254,6 +292,7 @@ class SettingsViewModel @Inject constructor(
|
||||
private data class ViewCustomization(
|
||||
val quickSwitch: QuickSwitchConfig,
|
||||
val drawerOrder: List<CalendarView>,
|
||||
val monthViewStyle: MonthViewStyle,
|
||||
)
|
||||
|
||||
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
||||
@@ -460,6 +499,26 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setShowWeekNumbers(enabled) }
|
||||
}
|
||||
|
||||
fun setTodayButtonInToolbar(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the launcher label between "Calendula" and "Calendar" (issue #44).
|
||||
* The card highlights immediately, then settles on whatever the component
|
||||
* state actually reports — the two `setComponentEnabledSetting` calls are
|
||||
* binder round-trips, so they don't belong on the main thread either.
|
||||
*/
|
||||
fun setLauncherName(name: LauncherName) {
|
||||
_launcherName.value = name
|
||||
viewModelScope.launch {
|
||||
_launcherName.value = withContext(io) {
|
||||
launcherNameManager.set(name)
|
||||
launcherNameManager.current()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||
viewModelScope.launch {
|
||||
prefs.setPastEventDisplay(mode)
|
||||
@@ -510,6 +569,10 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.updateQuickSwitch { it.copy(order = order) } }
|
||||
}
|
||||
|
||||
fun setMonthViewStyle(style: MonthViewStyle) {
|
||||
viewModelScope.launch { prefs.setMonthViewStyle(style) }
|
||||
}
|
||||
|
||||
fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -49,7 +48,6 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -77,8 +75,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
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
|
||||
@@ -92,8 +92,9 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||
@@ -143,6 +144,7 @@ fun WeekScreen(
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
todayInToolbar: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: WeekViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -180,6 +182,13 @@ fun WeekScreen(
|
||||
else -> true
|
||||
}
|
||||
|
||||
// Drives whether the title carries the year. Falls back to the clock only
|
||||
// while the first load is in flight, when there is no state to read today from.
|
||||
val currentYear = when (val s = state) {
|
||||
is WeekUiState.Success -> s.today.year
|
||||
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
||||
}
|
||||
|
||||
// Slide direction for the week transition: +1 = next, -1 = prev, 0 = jump.
|
||||
var slideDir by remember { mutableIntStateOf(0) }
|
||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||
@@ -228,16 +237,19 @@ fun WeekScreen(
|
||||
topBar = {
|
||||
WeekTopBar(
|
||||
weekStart = weekStart,
|
||||
currentYear = currentYear,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = jumpToToday,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
CalendarFabColumn(
|
||||
todayVisible = !isOnCurrentWeek,
|
||||
todayVisible = !isOnCurrentWeek && !todayInToolbar,
|
||||
todayText = stringResource(R.string.week_today_action),
|
||||
onToday = jumpToToday,
|
||||
onCreate = {
|
||||
@@ -283,8 +295,6 @@ private fun WeekContent(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val threshold = with(density) { 24.dp.toPx() }
|
||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
@@ -316,20 +326,7 @@ private fun WeekContent(
|
||||
// vertical scroll: a horizontal drag only crosses *this* detector's slop,
|
||||
// while a vertical drag is consumed by the inner scroll first — so the two
|
||||
// gestures coexist without fighting.
|
||||
val swipeModifier = Modifier.pointerInput(Unit) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { dragAccum = 0f },
|
||||
onDragEnd = {
|
||||
when {
|
||||
dragAccum < -threshold -> onSwipeNext()
|
||||
dragAccum > threshold -> onSwipePrev()
|
||||
}
|
||||
dragAccum = 0f
|
||||
},
|
||||
onDragCancel = { dragAccum = 0f },
|
||||
onHorizontalDrag = { _, drag -> dragAccum += drag },
|
||||
)
|
||||
}
|
||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
@@ -395,16 +392,20 @@ private fun WeekSuccess(
|
||||
@Composable
|
||||
private fun WeekTopBar(
|
||||
weekStart: LocalDate,
|
||||
currentYear: Int,
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = formatWeekRange(weekStart),
|
||||
text = formatWeekTitle(weekStart, locale, currentYear),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
@@ -417,6 +418,7 @@ private fun WeekTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TodayAction(show = showTodayButton, onToday = onToday)
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
@@ -854,18 +856,29 @@ private fun WeekLoading() {
|
||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String =
|
||||
formatMinuteOfDay(min, is24Hour, locale)
|
||||
|
||||
private fun formatWeekRange(weekStart: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
val end = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
||||
val monthName = { d: LocalDate ->
|
||||
java.time.Month.of(d.month.ordinal + 1).getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
}
|
||||
return if (weekStart.month == end.month && weekStart.year == end.year) {
|
||||
"${weekStart.day}.–${end.day}. ${monthName(weekStart)} ${weekStart.year}"
|
||||
} else if (weekStart.year == end.year) {
|
||||
"${weekStart.day}. ${monthName(weekStart)} – ${end.day}. ${monthName(end)} ${end.year}"
|
||||
} else {
|
||||
"${weekStart.day}. ${monthName(weekStart)} ${weekStart.year} – " +
|
||||
"${end.day}. ${monthName(end)} ${end.year}"
|
||||
}
|
||||
/**
|
||||
* The week's title: just the month [weekStart] falls in.
|
||||
*
|
||||
* The day numbers are already in the column headers directly below, so spelling
|
||||
* out "13.–19. Jul" restates them in the widest string in the bar. Naming the
|
||||
* month of [weekStart] means a week straddling a boundary keeps the outgoing
|
||||
* month until it is fully gone — a week is seven contiguous days, so the earlier
|
||||
* month has a day in it exactly while [weekStart] is still inside it. That also
|
||||
* makes the *month* depend on nothing but [weekStart], so it cannot drift with
|
||||
* the direction you paged in from.
|
||||
*
|
||||
* The *year* is decided from the whole week, not just its start: a week running
|
||||
* Dec 28 – Jan 3 has four of its seven visible columns in the new year, so
|
||||
* "December" with no year would be actively misleading while the reader is
|
||||
* looking straight at January dates.
|
||||
*/
|
||||
private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String {
|
||||
val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
||||
return formatCalendarTitle(
|
||||
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
|
||||
locale = locale,
|
||||
currentYear = currentYear,
|
||||
skeleton = "LLLL",
|
||||
forceYear = weekEnd.year != weekStart.year,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -131,13 +131,14 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
||||
// the composition from Glance state, so changing the range is a plain
|
||||
// recomposition and never depends on the widget session restarting.
|
||||
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone)
|
||||
val windowEnd = anchor.plus(AgendaRange.MAX_CUSTOM_DAYS - 1, DateTimeUnit.DAY)
|
||||
val instances = ep.calendarRepository().instances(window).first()
|
||||
val is24Hour = prefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
||||
val soften = prefs.softenCalendarColors.first()
|
||||
return AgendaWidgetData.Ready(
|
||||
today = anchor,
|
||||
days = groupAgendaDays(anchor, instances, zone),
|
||||
days = groupAgendaDays(anchor, windowEnd, instances, zone),
|
||||
is24Hour = is24Hour,
|
||||
soften = soften,
|
||||
weekStart = weekStart,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Size tiers a widget scales its typography and metrics across (#51).
|
||||
*
|
||||
* Shared by every Glance widget so the bucketing rule can't drift between them:
|
||||
* each widget keeps its own metrics table, but they all agree on *when* a widget
|
||||
* counts as compact, regular, large or extra-large. Both widgets already declare
|
||||
* [androidx.glance.appwidget.SizeMode.Exact], so the composition sees the live
|
||||
* size via `LocalSize.current` and passes it to [scaleFor].
|
||||
*
|
||||
* Kept in a pure, Glance-free file (only `compose.ui.unit`) so the bucketing is
|
||||
* covered by plain JVM tests.
|
||||
*/
|
||||
internal enum class WidgetScale { COMPACT, REGULAR, LARGE, XLARGE }
|
||||
|
||||
/**
|
||||
* Chrome a widget spends before its first content row: outer vertical padding
|
||||
* plus a header row and its spacer. Subtracted from the raw height so the height
|
||||
* thresholds below talk about *usable* space rather than gross widget height.
|
||||
*/
|
||||
private val CHROME_HEIGHT = 60.dp
|
||||
|
||||
/**
|
||||
* Buckets a live widget size into a [WidgetScale] by **width**.
|
||||
*
|
||||
* Width is the right axis: it governs how much of a title fits on a row, so it's
|
||||
* what should drive type size. Height only decides how many rows are visible — a
|
||||
* tall, narrow widget wants *more events*, not bigger text — so it never raises
|
||||
* the tier. It does act as a **cap**, though: a genuinely squashed widget is
|
||||
* stepped back down so it can't keep oversized type in a sliver of space.
|
||||
*
|
||||
* The width thresholds are spread across the range a phone can actually produce
|
||||
* (~180dp up to roughly the screen width) rather than over a theoretical range,
|
||||
* so the tiers are reachable in practice. Calibrated on-device (Pixel / Nova): a
|
||||
* compact 222dp-wide widget stays COMPACT (the app's baseline, unchanged) and a
|
||||
* full-width 378dp one reaches LARGE. XLARGE is reserved for genuinely wide
|
||||
* surfaces — tablets, foldables, landscape — where the extra size reads well.
|
||||
*
|
||||
* The height cap is deliberately generous: it exists to catch a widget squashed
|
||||
* to one or two rows, **not** to gate ordinary placements. A full-width widget at
|
||||
* the usual three cells tall (~270dp) must still reach the tier its width earned
|
||||
* — that is exactly the resize #51 reports, and an aggressive cap would make the
|
||||
* whole feature a no-op for it.
|
||||
*/
|
||||
internal fun scaleFor(size: DpSize): WidgetScale {
|
||||
val byWidth = when {
|
||||
size.width < 260.dp -> WidgetScale.COMPACT
|
||||
size.width < 330.dp -> WidgetScale.REGULAR
|
||||
size.width < 420.dp -> WidgetScale.LARGE
|
||||
else -> WidgetScale.XLARGE
|
||||
}
|
||||
// Height can only ever pull the tier *down*, never push it up: a squashed
|
||||
// widget would otherwise keep the big type its width earned and look absurd
|
||||
// in the little space left. Keeping this a cap (rather than a second scaling
|
||||
// axis) is what preserves "tall and narrow shows more events, not bigger
|
||||
// text". Thresholds are usable height — roughly one, two and three rows of
|
||||
// breathing room once the header is paid for.
|
||||
val usable = size.height - CHROME_HEIGHT
|
||||
val heightCap = when {
|
||||
usable < 70.dp -> WidgetScale.COMPACT
|
||||
usable < 130.dp -> WidgetScale.REGULAR
|
||||
usable < 200.dp -> WidgetScale.LARGE
|
||||
else -> WidgetScale.XLARGE
|
||||
}
|
||||
return minOf(byWidth, heightCap)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package de.jeanlucmakiola.calendula.widget.agenda
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetScale
|
||||
|
||||
/**
|
||||
* Horizontal layout constants for an agenda event row. These don't scale with the
|
||||
* tier — a wider stripe or gap would eat title width, which is the thing the row
|
||||
* is short of — but [TEXT_INDENT] is *derived* from them so the day header and
|
||||
* the "nothing left today" line can never drift out of alignment with the event
|
||||
* title column the way a hardcoded 19dp could.
|
||||
*/
|
||||
internal val ROW_H_PAD = 4.dp
|
||||
internal val STRIPE_WIDTH = 5.dp
|
||||
internal val STRIPE_GAP = 10.dp
|
||||
|
||||
/** Left indent that lines non-event text up with the event title column. */
|
||||
internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP
|
||||
|
||||
/**
|
||||
* The width band a *default* agenda placement can land in, per
|
||||
* `app/src/main/res/xml/appwidget_info_agenda.xml` (`android:minWidth="180dp"`,
|
||||
* `android:targetCellWidth="3"`). Measured at 222dp on a Pixel running Nova, but
|
||||
* launcher cell grids vary, so the whole band — not one measured point — has to
|
||||
* stay [WidgetScale.COMPACT] for the "default size is unchanged" promise of #51
|
||||
* to hold. A test pins that.
|
||||
*
|
||||
* If the provider's `targetCellWidth` ever changes, this band and the first
|
||||
* width threshold in [de.jeanlucmakiola.calendula.widget.scaleFor] must be
|
||||
* revisited together.
|
||||
*/
|
||||
internal val AGENDA_DEFAULT_WIDTH_BAND = 180.dp..255.dp
|
||||
|
||||
/**
|
||||
* Every size the agenda widget varies by tier. Values that genuinely shouldn't
|
||||
* grow (the horizontal row constants above, corner radii) stay constants rather
|
||||
* than routing through here.
|
||||
*/
|
||||
internal data class AgendaMetrics(
|
||||
val title: TextUnit, // header "Upcoming"
|
||||
val dayHeader: TextUnit, // day label ("Today · …")
|
||||
val eventTitle: TextUnit, // event title line
|
||||
val eventTime: TextUnit, // time/location line
|
||||
val placeholder: TextUnit, // "no more events today" (#35)
|
||||
val message: TextUnit, // empty/permission centred message
|
||||
val stripeH: Dp, // coloured event stripe height
|
||||
val iconImage: Dp, // header action icon glyph
|
||||
val iconBox: Dp, // header action touch target
|
||||
val rowVPad: Dp, // event row vertical padding
|
||||
val dayHeaderTopPad: Dp, // space above a day header
|
||||
) {
|
||||
/**
|
||||
* Resolves the stripe height against the user's system font scale.
|
||||
*
|
||||
* The stripe is a [Dp] but the two text lines it sits beside are `sp`, so
|
||||
* they scale with the accessibility font setting and the stripe does not —
|
||||
* at "Largest" the text outgrows the stripe and it visibly under-runs the row
|
||||
* it is supposed to mark. Multiplying by the same factor keeps them locked,
|
||||
* and at the default scale of 1.0 reproduces the tier's value exactly.
|
||||
*/
|
||||
fun scaledForFont(fontScale: Float): AgendaMetrics =
|
||||
if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale)
|
||||
}
|
||||
|
||||
/*
|
||||
* Type sizes are anchored to the Material 3 type scale (see the `material-3`
|
||||
* skill's typography reference) rather than invented: 16sp is Title Medium, 14sp
|
||||
* Body Medium / Title Small, 12sp Body Small, 16sp Body Large, 22sp Title Large.
|
||||
*
|
||||
* Two documented deviations:
|
||||
*
|
||||
* ‡ COMPACT's 13sp day header is off-scale. It is held there deliberately —
|
||||
* COMPACT reproduces the widget's original constants verbatim so a
|
||||
* default-sized widget looks exactly as it did (#51), and snapping it to
|
||||
* Title Small (14sp) would break that promise for a 1sp gain.
|
||||
*
|
||||
* † Above Title Medium the M3 scale jumps 16 → 22 → 24 with nothing in between,
|
||||
* which is far too coarse for four widget tiers. Where a role would force a
|
||||
* ≥1.4x step between adjacent tiers we hold an interpolated value instead and
|
||||
* mark it. The endpoints stay on real roles.
|
||||
*/
|
||||
|
||||
private val COMPACT_METRICS = AgendaMetrics(
|
||||
title = 16.sp, // M3 Title Medium
|
||||
dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline
|
||||
eventTitle = 14.sp, // M3 Body Medium
|
||||
eventTime = 12.sp, // M3 Body Small
|
||||
placeholder = 14.sp, // M3 Body Medium
|
||||
message = 14.sp, // M3 Body Medium
|
||||
stripeH = 36.dp,
|
||||
iconImage = 22.dp,
|
||||
iconBox = 40.dp,
|
||||
rowVPad = 4.dp,
|
||||
dayHeaderTopPad = 10.dp,
|
||||
)
|
||||
|
||||
private val REGULAR_METRICS = AgendaMetrics(
|
||||
title = 18.sp, // †
|
||||
dayHeader = 14.sp, // M3 Title Small
|
||||
eventTitle = 16.sp, // M3 Body Large
|
||||
eventTime = 14.sp, // M3 Body Medium
|
||||
placeholder = 16.sp, // M3 Body Large
|
||||
message = 16.sp, // M3 Body Large
|
||||
stripeH = 40.dp,
|
||||
iconImage = 24.dp,
|
||||
iconBox = 44.dp,
|
||||
rowVPad = 5.dp,
|
||||
dayHeaderTopPad = 11.dp,
|
||||
)
|
||||
|
||||
private val LARGE_METRICS = AgendaMetrics(
|
||||
title = 20.sp, // †
|
||||
dayHeader = 16.sp, // M3 Title Medium
|
||||
eventTitle = 18.sp, // †
|
||||
eventTime = 14.sp, // M3 Body Medium — the secondary line steps more slowly
|
||||
placeholder = 18.sp, // † on purpose, so the title keeps its
|
||||
message = 18.sp, // † lead and the hierarchy survives.
|
||||
stripeH = 46.dp,
|
||||
iconImage = 26.dp,
|
||||
iconBox = 48.dp,
|
||||
rowVPad = 6.dp,
|
||||
dayHeaderTopPad = 12.dp,
|
||||
)
|
||||
|
||||
private val XLARGE_METRICS = AgendaMetrics(
|
||||
title = 22.sp, // M3 Title Large
|
||||
dayHeader = 18.sp, // †
|
||||
eventTitle = 20.sp, // †
|
||||
eventTime = 16.sp, // M3 Body Large
|
||||
placeholder = 20.sp, // †
|
||||
message = 20.sp, // †
|
||||
stripeH = 52.dp,
|
||||
iconImage = 28.dp,
|
||||
iconBox = 52.dp,
|
||||
rowVPad = 8.dp,
|
||||
dayHeaderTopPad = 14.dp,
|
||||
)
|
||||
|
||||
/** Indexed by [WidgetScale.ordinal] so lookup allocates nothing per recomposition. */
|
||||
private val AGENDA_METRICS = listOf(
|
||||
COMPACT_METRICS,
|
||||
REGULAR_METRICS,
|
||||
LARGE_METRICS,
|
||||
XLARGE_METRICS,
|
||||
)
|
||||
|
||||
internal fun metricsFor(scale: WidgetScale): AgendaMetrics = AGENDA_METRICS[scale.ordinal]
|
||||
@@ -5,7 +5,6 @@ import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.glance.ColorFilter
|
||||
@@ -14,9 +13,11 @@ import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
@@ -49,16 +50,19 @@ import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||
import de.jeanlucmakiola.calendula.widget.scaleFor
|
||||
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||
import de.jeanlucmakiola.calendula.widget.today
|
||||
@@ -105,6 +109,19 @@ class AgendaWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
// Exact so the composition sees the widget's live size and can scale type/rows
|
||||
// from it ([scaleFor]/[metricsFor]); at the default size that resolves to
|
||||
// COMPACT, i.e. the layout is unchanged (#51). MonthWidget already does the
|
||||
// same.
|
||||
//
|
||||
// Note Exact still asks Glance for one RemoteViews per host size (typically
|
||||
// portrait + landscape) where the old SizeMode.Single produced exactly one —
|
||||
// so the serialized payload roughly doubles. That is why the row list below is
|
||||
// capped: an uncapped agenda (the range goes up to AgendaRange.MAX_CUSTOM_DAYS
|
||||
// = 365) could otherwise push the RemoteViews past the binder transaction
|
||||
// limit and the host would just show "Problem loading widget".
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val data = context.loadAgendaWidgetData()
|
||||
val dark = (context.resources.configuration.uiMode and
|
||||
@@ -124,26 +141,41 @@ class RefreshAgendaAction : ActionCallback {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* user scrolling a home-screen widget past a hundred rows is not a case worth
|
||||
* risking a failed update for.
|
||||
*/
|
||||
private const val MAX_AGENDA_ROWS = 100
|
||||
|
||||
/** Flat row model so the [LazyColumn] can mix day headers and events. */
|
||||
private sealed interface AgendaRow {
|
||||
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
|
||||
data class Event(val event: EventInstance) : AgendaRow
|
||||
data class Event(val date: LocalDate, val event: EventInstance) : AgendaRow
|
||||
/** "Nothing left today" line under an anchored, event-less today (#35). */
|
||||
data class Placeholder(val date: LocalDate) : AgendaRow
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
// Type and row metrics scale with the widget's live size (SizeMode.Exact); a
|
||||
// short/compact widget resolves to COMPACT, leaving the layout unchanged (#51).
|
||||
// The stripe is then re-resolved against the system font scale so it tracks the
|
||||
// sp-sized text beside it instead of drifting at large accessibility settings.
|
||||
val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
|
||||
val metrics = metricsFor(scaleFor(LocalSize.current)).scaledForFont(fontScale)
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(GlanceTheme.colors.surface)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
AgendaHeader()
|
||||
AgendaHeader(metrics)
|
||||
Spacer(GlanceModifier.height(4.dp))
|
||||
when (data) {
|
||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
|
||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
|
||||
is AgendaWidgetData.Ready -> {
|
||||
// Range read reactively from per-instance Glance state (falls back
|
||||
// to the saved pref for a freshly placed widget), then the wide
|
||||
@@ -177,7 +209,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
enabled = showToday,
|
||||
)
|
||||
if (visibleDays.isEmpty()) {
|
||||
WidgetMessage(R.string.agenda_empty_title)
|
||||
WidgetMessage(R.string.agenda_empty_title, metrics)
|
||||
} else {
|
||||
val rows = buildList {
|
||||
visibleDays.forEach { day ->
|
||||
@@ -185,22 +217,28 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
if (day.events.isEmpty()) {
|
||||
add(AgendaRow.Placeholder(day.date))
|
||||
} else {
|
||||
day.events.forEach { add(AgendaRow.Event(it)) }
|
||||
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bound the payload, then drop a day header the cut left
|
||||
// stranded with nothing under it.
|
||||
.take(MAX_AGENDA_ROWS)
|
||||
.dropLastWhile { it is AgendaRow.Header }
|
||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||
items(rows.size) { index ->
|
||||
when (val row = rows[index]) {
|
||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
||||
is AgendaRow.Placeholder -> PlaceholderRow(row.date)
|
||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
|
||||
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
|
||||
is AgendaRow.Event -> EventRow(
|
||||
event = row.event,
|
||||
day = row.date,
|
||||
dark = dark,
|
||||
soften = data.soften,
|
||||
is24Hour = data.is24Hour,
|
||||
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
||||
row.event.hasEnded(data.now),
|
||||
metrics = metrics,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -212,7 +250,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaHeader() {
|
||||
private fun AgendaHeader(metrics: AgendaMetrics) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
@@ -224,7 +262,7 @@ private fun AgendaHeader() {
|
||||
text = context.getString(R.string.widget_agenda_title),
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.primary,
|
||||
fontSize = 16.sp,
|
||||
fontSize = metrics.title,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
modifier = GlanceModifier
|
||||
@@ -237,6 +275,7 @@ private fun AgendaHeader() {
|
||||
resId = R.drawable.ic_widget_refresh,
|
||||
contentDescription = context.getString(R.string.widget_refresh),
|
||||
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
|
||||
metrics = metrics,
|
||||
)
|
||||
IconButton(
|
||||
resId = R.drawable.ic_widget_add,
|
||||
@@ -246,39 +285,50 @@ private fun AgendaHeader() {
|
||||
MainActivity.openCreateIntent(context, today(systemZone())),
|
||||
),
|
||||
),
|
||||
metrics = metrics,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IconButton(resId: Int, contentDescription: String, onClick: GlanceModifier) {
|
||||
private fun IconButton(
|
||||
resId: Int,
|
||||
contentDescription: String,
|
||||
onClick: GlanceModifier,
|
||||
metrics: AgendaMetrics,
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier.size(40.dp).then(onClick),
|
||||
modifier = GlanceModifier.size(metrics.iconBox).then(onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(resId),
|
||||
contentDescription = contentDescription,
|
||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||
modifier = GlanceModifier.size(22.dp),
|
||||
modifier = GlanceModifier.size(metrics.iconImage),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
||||
private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
Text(
|
||||
text = agendaDayLabel(context, date, today),
|
||||
style = TextStyle(
|
||||
color = if (date == today) GlanceTheme.colors.primary
|
||||
else GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = 13.sp,
|
||||
fontSize = metrics.dayHeader,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp)
|
||||
.padding(
|
||||
start = 8.dp,
|
||||
end = 8.dp,
|
||||
top = metrics.dayHeaderTopPad,
|
||||
bottom = metrics.rowVPad,
|
||||
)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
||||
@@ -293,14 +343,19 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
||||
* plain too (a stripe + text, not cards), so a card here would look out of place.
|
||||
*/
|
||||
@Composable
|
||||
private fun PlaceholderRow(date: LocalDate) {
|
||||
private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
Text(
|
||||
text = context.getString(R.string.agenda_no_more_today),
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder),
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 19.dp, end = 8.dp, top = 2.dp, bottom = 6.dp)
|
||||
.padding(
|
||||
start = TEXT_INDENT,
|
||||
end = 8.dp,
|
||||
top = 2.dp,
|
||||
bottom = metrics.rowVPad + 2.dp,
|
||||
)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
||||
@@ -312,10 +367,12 @@ private fun PlaceholderRow(date: LocalDate) {
|
||||
@Composable
|
||||
private fun EventRow(
|
||||
event: EventInstance,
|
||||
day: LocalDate,
|
||||
dark: Boolean,
|
||||
soften: Boolean,
|
||||
is24Hour: Boolean,
|
||||
dimmed: Boolean,
|
||||
metrics: AgendaMetrics,
|
||||
) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
@@ -328,7 +385,7 @@ private fun EventRow(
|
||||
Row(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||
.padding(horizontal = ROW_H_PAD, vertical = metrics.rowVPad)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openEventIntent(
|
||||
@@ -344,29 +401,29 @@ private fun EventRow(
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(5.dp)
|
||||
.height(36.dp)
|
||||
.width(STRIPE_WIDTH)
|
||||
.height(metrics.stripeH)
|
||||
.cornerRadius(3.dp)
|
||||
.background(stripeColor),
|
||||
) {}
|
||||
Spacer(GlanceModifier.width(10.dp))
|
||||
Spacer(GlanceModifier.width(STRIPE_GAP))
|
||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = titleColor, fontSize = 14.sp),
|
||||
style = TextStyle(color = titleColor, fontSize = metrics.eventTitle),
|
||||
)
|
||||
Text(
|
||||
text = eventTimeSummary(context, event, is24Hour),
|
||||
text = eventTimeSummary(context, event, day, is24Hour),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WidgetMessage(resId: Int) {
|
||||
private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
Box(
|
||||
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
|
||||
@@ -374,7 +431,7 @@ private fun WidgetMessage(resId: Int) {
|
||||
) {
|
||||
Text(
|
||||
text = context.getString(resId),
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.message),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -396,11 +453,20 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
private fun eventTimeSummary(context: Context, event: EventInstance, is24Hour: Boolean): String {
|
||||
val time = if (event.isAllDay) {
|
||||
context.getString(R.string.event_detail_all_day)
|
||||
} else {
|
||||
"${formatTime(event.start, is24Hour)} – ${formatTime(event.end, is24Hour)}"
|
||||
private fun eventTimeSummary(
|
||||
context: Context,
|
||||
event: EventInstance,
|
||||
day: LocalDate,
|
||||
is24Hour: Boolean,
|
||||
): String {
|
||||
val time = when (val label = agendaTimeLabel(event, day, zone())) {
|
||||
AgendaTimeLabel.AllDay -> context.getString(R.string.event_detail_all_day)
|
||||
is AgendaTimeLabel.Starts ->
|
||||
context.getString(R.string.agenda_span_starts, formatTime(label.start, is24Hour))
|
||||
is AgendaTimeLabel.Ends ->
|
||||
context.getString(R.string.agenda_span_ends, formatTime(label.end, is24Hour))
|
||||
is AgendaTimeLabel.Range ->
|
||||
"${formatTime(label.start, is24Hour)} – ${formatTime(label.end, is24Hour)}"
|
||||
}
|
||||
val location = event.location?.takeIf { it.isNotBlank() }
|
||||
return if (location != null) "$time · $location" else time
|
||||
|
||||
@@ -49,6 +49,7 @@ import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
@@ -162,7 +163,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo
|
||||
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
||||
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
||||
|
||||
MonthHeader(label = monthLabel(ym))
|
||||
MonthHeader(label = monthLabel(ym, source.today.year))
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
WeekdayHeader(weekStart = source.weekStart, colW = colW)
|
||||
weeks.forEach { week ->
|
||||
@@ -480,9 +481,13 @@ private fun PermissionMessage() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun monthLabel(month: YearMonth): String {
|
||||
val locale = Locale.getDefault()
|
||||
val name = java.time.Month.of(month.month.ordinal + 1)
|
||||
.getDisplayName(JavaTextStyle.FULL, locale)
|
||||
return "$name ${month.year}"
|
||||
}
|
||||
// Locale.getDefault() rather than currentLocale(): Glance has no
|
||||
// LocalConfiguration, and the widget is re-rendered on a configuration change
|
||||
// anyway (same convention as AgendaWidget).
|
||||
private fun monthLabel(month: YearMonth, currentYear: Int): String =
|
||||
formatCalendarTitle(
|
||||
date = java.time.LocalDate.of(month.year, month.month.ordinal + 1, 1),
|
||||
locale = Locale.getDefault(),
|
||||
currentYear = currentYear,
|
||||
skeleton = "LLLL",
|
||||
)
|
||||
|
||||
246
app/src/main/res/values-ar/strings.xml
Normal file
246
app/src/main/res/values-ar/strings.xml
Normal file
@@ -0,0 +1,246 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
<string name="state_loading">جارٍ التحميل…</string>
|
||||
<string name="state_retry">إعادة المحاولة</string>
|
||||
<string name="app_tagline">تقويم عصري.</string>
|
||||
<string name="state_failure_unknown">حدث خطأ ما.</string>
|
||||
<string name="state_failure_permission">الوصول إلى التقويم مطلوب.</string>
|
||||
<string name="state_failure_permission_action">منح الوصول</string>
|
||||
<string name="state_failure_no_calendars">لم يتم تكوين أي تقويمات.</string>
|
||||
<string name="state_failure_no_calendars_action">فتح إعدادات تقويم النظام</string>
|
||||
<string name="state_failure_provider">تعذّر قراءة التقويم.</string>
|
||||
<string name="permission_rationale_title">شاهد جميع أحداثك، بشكل جميل</string>
|
||||
<string name="permission_rationale_body">Calendula يحتاج للوصول إلى تقويمك لعرض و إدارة أحداثك. هذا كل ما يطلبه مقدمًا — ولا يخرج أي شيء من جهازك على الإطلاق.</string>
|
||||
<string name="permission_request_button">منح الوصول إلى التقويم</string>
|
||||
<string name="permission_denied_title">رُفِض الوصول إلى التقويم</string>
|
||||
<string name="permission_denied_body">Calendula لا يمكنه عرض الأحداث بدون الوصول إلى التقويم. يمكنك منحه مُجددًا في إعدادات النظام.</string>
|
||||
<string name="permission_retry_button">أعِد المحاولة</string>
|
||||
<string name="permission_open_settings_button">فتح إعدادات النظام</string>
|
||||
<string name="permission_benefit_private_title">يبقى على جهازك</string>
|
||||
<string name="permission_benefit_private_body">تقويماتك تتِم قراءتها محليًا ولا تُغادر الهاتف أبدًا.</string>
|
||||
<string name="permission_benefit_sync_title">جميع تقويماتك، مع بعض</string>
|
||||
<string name="permission_benefit_sync_body">جوجل، CalDAV، محلي — أي شيء تمت مزامنته مع الجهاز يظهر فورًا.</string>
|
||||
<string name="permission_benefit_privacy_title">لا تتبع، أبدًا</string>
|
||||
<string name="permission_benefit_privacy_body">لا تتبع عن بُعد، لا تحليلات، لا إعلانات.</string>
|
||||
<string name="permission_privacy_footnote">يبقى على جهازك · لا يوجد إذن بالإنترنت</string>
|
||||
<string name="month_prev">الشهر السابق</string>
|
||||
<string name="month_next">الشهر القادم</string>
|
||||
<string name="month_today_action">اليوم</string>
|
||||
<string name="month_action_settings">إعدادات</string>
|
||||
<string name="settings_title">الإعدادات</string>
|
||||
<string name="settings_theme">الثيم</string>
|
||||
<string name="settings_theme_system">النظام</string>
|
||||
<string name="settings_theme_light">فاتح</string>
|
||||
<string name="settings_theme_dark">داكن</string>
|
||||
<string name="month_open_menu">افتح القائمة</string>
|
||||
<string name="month_more_actions">المزيد من الإجراءات</string>
|
||||
<string name="event_detail_edit">تعديل</string>
|
||||
<string name="event_detail_delete">حذف</string>
|
||||
<string name="event_detail_share">مشاركة</string>
|
||||
<string name="event_detail_duplicate">نسخ</string>
|
||||
<string name="event_share_chooser_title">مشاركة الحدث</string>
|
||||
<string name="day_today_action">اليوم</string>
|
||||
<string name="week_today_action">هذا الأسبوع</string>
|
||||
<string name="week_number_label">أسبوع</string>
|
||||
<string name="event_detail_back">العودة</string>
|
||||
<string name="month_a11y_today_prefix">اليوم</string>
|
||||
<string name="event_share_failed">تعذّر مشاركة هذا الحدث.</string>
|
||||
<string name="event_delete_title">حذف الحدث؟</string>
|
||||
<string name="event_delete_body">الحدث أُزيل من تقويمك ومن كل جهاز تتم مزامنته معه.</string>
|
||||
<string name="event_delete_recurring_title">حذف الحدث المتكرر</string>
|
||||
<string name="event_delete_option_occurrence">فقط هذا الحدث</string>
|
||||
<string name="event_delete_option_following">هذا وجميع الأحداث اللاحقة</string>
|
||||
<string name="event_delete_option_series">جميع الأحداث في السلسلة</string>
|
||||
<string name="event_edit_recurring_title">تعديل الحدث المتكرر</string>
|
||||
<string name="event_delete_failed">تعذّر حذف الحدث</string>
|
||||
<string name="event_delete_write_denied">Calendula يحتاج إلى صلاحية للكتابة لحذف الأحداث</string>
|
||||
<string name="dialog_cancel">إلغاء</string>
|
||||
<string name="dialog_ok">حسنًا</string>
|
||||
<string name="event_edit_new_title">حدث جديد</string>
|
||||
<string name="event_edit_close">إغلاق</string>
|
||||
<string name="event_edit_save">حفظ</string>
|
||||
<string name="event_edit_title_hint">إضافة عنوان</string>
|
||||
<string name="event_edit_managed_hint">تتم الإدارة بواسطة ”%1$s“ — العنوان، التاريخ و التكرار تبقى في مزامنة من جهات اتصالك. التذكيرات، الموقع والملاحظات يمكن لك تعديلها.</string>
|
||||
<string name="event_edit_starts">يبدأ</string>
|
||||
<string name="event_edit_ends">ينتهي</string>
|
||||
<string name="event_edit_error_end_before_start">ينتهي قبل أن يبدأ</string>
|
||||
<string name="event_edit_error_no_calendar">لا تتوفر تقويمات قابلة للكتابة</string>
|
||||
<string name="event_edit_save_failed">تعذّر حفظ الحدث</string>
|
||||
<string name="event_edit_write_denied">Calendula يحتاج إلى صلاحية للكتابة لإنشاء أحداث</string>
|
||||
<string name="event_edit_add_reminder">إضافة تذكير</string>
|
||||
<string name="event_edit_remove_reminder">إزالة التذكير</string>
|
||||
<string name="event_edit_attendees">الضيوف</string>
|
||||
<string name="event_edit_add_guest">إضافة ضيف</string>
|
||||
<string name="event_edit_add_guest_hint">إضافة ضيف بالبريد الإلكتروني…</string>
|
||||
<string name="event_edit_add_guest_from_contacts">إضافة من جهات الاتصال</string>
|
||||
<string name="event_edit_remove_guest">إزالة الضيف</string>
|
||||
<string name="event_edit_attendee_required">مطلوب</string>
|
||||
<string name="event_edit_attendee_optional">اختياري</string>
|
||||
<string name="event_edit_attendees_note_synced">Calendula لا يرسل دعوات. قد يرسل حساب التقويم الخاص بك بريدًا إلكترونيًا إلى الضيوف عند مزامنته.</string>
|
||||
<string name="event_edit_reminder_custom">مخصص</string>
|
||||
<string name="reminder_unit_minutes">دقائق</string>
|
||||
<string name="reminder_unit_hours">ساعات</string>
|
||||
<string name="event_edit_attendees_note_local">مخزّن على هذا الجهاز. لن يتم إشعار أي شخص.</string>
|
||||
<string name="event_edit_location_from_contacts">اختر عنوان من جهات الاتصال</string>
|
||||
<string name="event_edit_add">أضِف</string>
|
||||
<string name="reminder_unit_days">أيام</string>
|
||||
<string name="reminder_unit_weeks">أسابيع</string>
|
||||
<string name="event_edit_color_default">لون التقويم</string>
|
||||
<string name="event_edit_color_custom">لون مخصص</string>
|
||||
<string name="event_edit_visibility">مدى الرؤية</string>
|
||||
<string name="event_edit_availability">التَّوافر</string>
|
||||
<string name="event_edit_color">اللون</string>
|
||||
<string name="event_edit_color_reset">إعادة تعيين</string>
|
||||
<string name="event_edit_color_unsupported">غير مُتوفِّر لهذا التقويم</string>
|
||||
<string name="event_edit_color_unsupported_hint">هذا التقويم لا ينشر أي مجموعة ألوان. يمكنك السماح بألوان مخصصة لمثل هذه التقويمات في الإعدادات.</string>
|
||||
<string name="event_edit_color_sync_warning">هذا التقويم قد يزيل أو يستبدله اللون في مزامنته التالية.</string>
|
||||
<string name="event_edit_conflict_title">الحدث غُيِّر في مكان آخر</string>
|
||||
<string name="event_edit_conflict_body">أثناء قيامك بالتعديل، هذا الحدث تغيّر — عن طريق المزامنة أو تطبيق آخر. ماذا يجب أن يحدث لتغييراتك؟</string>
|
||||
<string name="event_edit_conflict_overwrite">حفظ تغييراتي</string>
|
||||
<string name="event_edit_conflict_discard">تجاهل تغييراتي</string>
|
||||
<string name="event_edit_gone_title">تم حذف الحدث</string>
|
||||
<string name="event_edit_conflict_discard_hint">الحدث يبقى كما هو الآن</string>
|
||||
<string name="import_reminder_prompt_title">هل تريد تطبيق تذكيرك الافتراضي؟</string>
|
||||
<string name="event_edit_gone_body">هذا الحدث حُذف في هذه الأثناء، على سبيل المثال على جهاز آخر. لم يعد من الممكن حفظ تغييراتك.</string>
|
||||
<string name="event_edit_more_fields">المزيد من الخيارات</string>
|
||||
<string name="event_access_public">علني</string>
|
||||
<string name="event_access_default">الافتراضي</string>
|
||||
<string name="event_availability_busy">مشغول</string>
|
||||
<string name="event_edit_recurrence_times">مرّات</string>
|
||||
<string name="recurrence_unit_days">أيام</string>
|
||||
<string name="recurrence_unit_weeks">أسابيع</string>
|
||||
<string name="recurrence_unit_months">شهور</string>
|
||||
<string name="recurrence_unit_years">سنوات</string>
|
||||
<string name="event_edit_recurrence_ends">ينتهي</string>
|
||||
<string name="event_edit_recurrence_end_never">أبدًا</string>
|
||||
<string name="event_edit_recurrence_end_until">في موعد</string>
|
||||
<string name="event_edit_recurrence_end_count">بعد عدد من المرّات</string>
|
||||
<string name="event_edit_recurrence_custom">مخصص</string>
|
||||
<string name="event_edit_recurrence_every">كل</string>
|
||||
<string name="event_edit_recurrence_none">لا يتكرر</string>
|
||||
<string name="import_reminder_prompt_body_none">تم استيراد هذا الحدث بدون أي تذكير.</string>
|
||||
<plurals name="import_reminder_prompt_body_existing">
|
||||
<item quantity="one">تم استيراد هذا الحدث مع %1$d تذكير.</item>
|
||||
<item quantity="two">تم استيراد هذا الحدث مع %1$d تذكيران.</item>
|
||||
<item quantity="few">تم استيراد هذا الحدث مع %1$d تذكيرات.</item>
|
||||
<item quantity="many">تم استيراد هذا الحدث مع %1$d تذكير.</item>
|
||||
<item quantity="other">تم استيراد هذا الحدث مع %1$d تذكير.</item>
|
||||
</plurals>
|
||||
<string name="event_edit_error_recurrence_ends_before_start">التكرار ينتهي قبل أن يبدأ الحدث</string>
|
||||
<string name="import_reminder_prompt_keep">اتركه كما هو</string>
|
||||
<string name="event_detail_calendar">التقويم</string>
|
||||
<string name="event_detail_calendar_unknown">تقويم غير معروف</string>
|
||||
<string name="event_detail_description">الوصف</string>
|
||||
<string name="event_detail_all_day">كل يوم</string>
|
||||
<string name="event_detail_location">الموقع</string>
|
||||
<string name="event_detail_attendees">الحضور</string>
|
||||
<string name="event_detail_recurrence">التكرار</string>
|
||||
<string name="recurrence_daily">كل يوم</string>
|
||||
<string name="recurrence_weekly">كل أسبوع</string>
|
||||
<string name="recurrence_monthly">كل شهر</string>
|
||||
<string name="recurrence_yearly">كل سنة</string>
|
||||
<string name="recurrence_every_n_days">كل %1$d أيام</string>
|
||||
<string name="recurrence_every_n_weeks">كل %1$d أسابيع</string>
|
||||
<string name="recurrence_every_n_months">كل %1$d أشهر</string>
|
||||
<string name="recurrence_every_n_years">كل %1$d سنوات</string>
|
||||
<string name="event_detail_not_found">هذا الحدث لم يعد موجودًا.</string>
|
||||
<string name="recurrence_with_until">%1$s حتى %2$s</string>
|
||||
<string name="recurrence_with_count">%1$s، %2$d مرّات</string>
|
||||
<string name="import_reminder_prompt_apply">طَبِّق الافتراضي</string>
|
||||
<string name="event_detail_recurring">حدث مُتكرر</string>
|
||||
<string name="recurrence_on_days">%1$s في %2$s</string>
|
||||
<string name="event_attendee_unknown">—</string>
|
||||
<string name="event_attendee_accepted">تم القُبول</string>
|
||||
<string name="event_attendee_declined">تم الرَفض</string>
|
||||
<string name="event_attendee_tentative">غير مُؤكِّد</string>
|
||||
<string name="event_attendee_needs_action">لا يوجد رد</string>
|
||||
<string name="event_detail_reminders">التذكيرات</string>
|
||||
<string name="event_detail_timezone">المنطقة الزمنية</string>
|
||||
<string name="event_status_tentative">غير مُؤكِّد</string>
|
||||
<string name="event_status_cancelled">ملغي</string>
|
||||
<string name="event_availability_free">متاح</string>
|
||||
<string name="event_access_private">خاص</string>
|
||||
<string name="event_access_confidential">سري</string>
|
||||
<string name="event_attendee_organizer">المُنظِّم</string>
|
||||
<string name="event_attendee_optional">اختياري</string>
|
||||
<string name="reminder_default">التذكير الافتراضي</string>
|
||||
<string name="event_detail_self_response">ردَّك: %1$s</string>
|
||||
<string name="event_attendee_resource">المُنسِّق</string>
|
||||
<string name="reminder_at_time">في وقت الحدث</string>
|
||||
<string name="event_untitled">(بدون عنوان)</string>
|
||||
<string name="reminder_channel_name">تذكيرات الحدث</string>
|
||||
<string name="reminder_channel_description">الإشعارات في أوقات التذكير الخاصه بأحداثك</string>
|
||||
<string name="reminder_onboarding_title">لا تفوّت أي حدث أبدًا</string>
|
||||
<string name="reminder_onboarding_body">Android لا يعرض تذكيرات الأحداث من نفسه — بل يجب أن يقوم تطبيق تقويم بذلك. دع Calendula يتولى هذه المهمة.</string>
|
||||
<string name="reminder_benefit_delivery_body">كل تذكير لأحداثك يصِل كإشعار، في الوقت المحدد تمامًا.</string>
|
||||
<string name="reminder_benefit_duplicates_title">هل تستخدم تطبيق تقويم ثانٍ؟</string>
|
||||
<string name="reminder_benefit_duplicates_body">إذا كان تطبيق آخر أيضًا ينشر تذكيرات، فستراهم مرتين — قم بإيقاف تشغيلهم هناك أو هنا.</string>
|
||||
<string name="reminder_benefit_reversible_title">يمكنك تغييره في أي وقت</string>
|
||||
<string name="reminder_onboarding_enable_button">شَغِّل التذكيرات</string>
|
||||
<string name="reminder_onboarding_skip_button">ليس الآن</string>
|
||||
<string name="reminder_action_snooze">تأجيل</string>
|
||||
<string name="reminder_action_dismiss">تجاهل</string>
|
||||
<string name="reminder_day_tomorrow">غدًا</string>
|
||||
<string name="reminder_day_yesterday">يوم أمس</string>
|
||||
<string name="view_month">شهر</string>
|
||||
<string name="view_week">أسبوع</string>
|
||||
<string name="view_day">يوم</string>
|
||||
<string name="view_agenda">جدول</string>
|
||||
<string name="drawer_jump_to_date">الانتقال إلى تاريخ</string>
|
||||
<string name="agenda_today_action">اليوم</string>
|
||||
<string name="agenda_header_today">اليوم</string>
|
||||
<string name="agenda_header_tomorrow">غدًا</string>
|
||||
<string name="view_section">طريقة العرض</string>
|
||||
<string name="search_hint">البحث عن الأحداث</string>
|
||||
<string name="search_clear">مسح</string>
|
||||
<string name="agenda_no_more_today">لا مزيد من الأحداث اليوم</string>
|
||||
<string name="search_empty">لا توجد أحداث مطابقة ”%1$s“.</string>
|
||||
<string name="search_idle_hint">ابحث عن أحداثك بالعنوان، الموقع أو الملاحظات.</string>
|
||||
<string name="search_back">الرجوع</string>
|
||||
<string name="search_action">البحث</string>
|
||||
<string name="widget_agenda_title">القادم</string>
|
||||
<string name="widget_agenda_label">جدول Calendula</string>
|
||||
<string name="widget_month_label">شهر Calendula</string>
|
||||
<string name="widget_new_event">حدث جديد</string>
|
||||
<string name="widget_needs_permission">افتح Calendula لمنح الوصول إلى التقويم</string>
|
||||
<string name="widget_prev_month">الشهر السابق</string>
|
||||
<string name="widget_next_month">الشهر القادم</string>
|
||||
<string name="widget_today">اليوم</string>
|
||||
<string name="filter_title">التقويمات</string>
|
||||
<string name="settings_back">الرجوع</string>
|
||||
<string name="back">رجوع</string>
|
||||
<string name="settings_section_appearance">المظهر</string>
|
||||
<string name="settings_dynamic_color">اللون الديناميكي</string>
|
||||
<string name="settings_dynamic_color_unavailable">يتطلب Android 12 أو أحدث</string>
|
||||
<string name="settings_default_view">طريقة العرض الافتراضية</string>
|
||||
<string name="settings_soften_colors">ألوان تقويم ناعمة</string>
|
||||
<string name="settings_soften_colors_summary">خفف ألوان التقويم والأحداث لتتناسب مع الثيم. قم بإيقافه لإظهار الألوان الخام من مصدر التقويم.</string>
|
||||
<string name="settings_font_headings">خط العناوين</string>
|
||||
<string name="settings_font_body">خط النص</string>
|
||||
<string name="settings_font_system">افتراضي النظام</string>
|
||||
<string name="settings_font_choose_file">اختر ملفًا…</string>
|
||||
<string name="settings_font_custom_selected">خط مخصص</string>
|
||||
<string name="settings_font_import_failed">تعذّر قراءة هذا الملف كخط</string>
|
||||
<string name="settings_week_start">يبدأ الأسبوع في</string>
|
||||
<string name="settings_week_start_auto">تلقائي</string>
|
||||
<string name="settings_time_format_auto">تلقائي</string>
|
||||
<string name="settings_time_format">تنسيق الوقت</string>
|
||||
<string name="settings_week_numbers">أرقام الأسبوع</string>
|
||||
<string name="settings_week_numbers_summary">إظهار أرقام أسابيع التقويم في عرض الشهر</string>
|
||||
<string name="settings_time_format_12h">١٢-ساعة (٢:٠٠ م)</string>
|
||||
<string name="settings_time_format_24h">٢٤-ساعة (١٤:٠٠)</string>
|
||||
<string name="agenda_range_day">اليوم</string>
|
||||
<string name="settings_agenda_show_today">إظهار اليوم دائمًا</string>
|
||||
<string name="settings_agenda_show_today_hint">اترك اليوم في أعلى الجدول و ودجته، حتى بعد عدم وجود أي شيء اليوم.</string>
|
||||
<string name="agenda_range_this_month">هذا الشهر</string>
|
||||
<string name="agenda_range_this_week">هذا الأسبوع</string>
|
||||
<string name="settings_past_events_show">إظهار</string>
|
||||
<string name="settings_past_events_hide">إخفاء</string>
|
||||
<string name="settings_agenda_header">جدول</string>
|
||||
</resources>
|
||||
@@ -475,19 +475,21 @@
|
||||
<string name="special_dates_default_title_anniversary">Jahrestag von {name} ({year})</string>
|
||||
<string name="special_dates_default_title_custom">{name}</string>
|
||||
<string name="settings_week_numbers">Kalenderwochen</string>
|
||||
<string name="settings_week_numbers_summary">Kalenderwochen der Monatsansicht anzeigen</string>
|
||||
<string name="settings_week_numbers_summary">Kalenderwochen in Monatsansicht zeigen</string>
|
||||
<string name="calendars_export_title">Kalender exportieren</string>
|
||||
<string name="calendars_export_hint">Wähle aus, welche Kalender in die .ics-Datei aufgenommen werden sollen.</string>
|
||||
<string name="calendars_export_action">Exportieren</string>
|
||||
<string name="calendars_restore_header">Wiederherstellen</string>
|
||||
<string name="calendars_restore_action">Aus .ics-Datei wiederherstellen</string>
|
||||
<string name="calendars_restore_hint">Importiere Ereignisse aus einem Backup oder einer anderen Kalender-App.</string>
|
||||
<string name="calendars_restore_hint">Einträge von einem Backup oder einer anderen Kalender App importieren.</string>
|
||||
<string name="import_done_dedup_note">Bereits im Kalender vorhandene Ereignisse wurden übersprungen.</string>
|
||||
<string name="import_done_added_label">Hinzugefügt</string>
|
||||
<string name="import_done_skipped_label">Duplikate</string>
|
||||
<string name="import_done_skipped_label">Dopplungen</string>
|
||||
<string name="import_button">Importieren</string>
|
||||
<plurals name="import_title_count">
|
||||
<item quantity="one">%d Ereignis wird importiert</item>
|
||||
<item quantity="other">%d Ereignisse werden importiert</item>
|
||||
</plurals>
|
||||
<string name="event_detail_duplicate">Duplikate</string>
|
||||
<string name="reminder_day_tomorrow">Morgen</string>
|
||||
</resources>
|
||||
|
||||
@@ -10,27 +10,27 @@
|
||||
<string name="app_tagline">Un calendrier moderne.</string>
|
||||
<string name="state_loading">Chargement…</string>
|
||||
<string name="state_retry">Réessayer</string>
|
||||
<string name="state_failure_unknown">Une erreur s\'est produit.</string>
|
||||
<string name="state_failure_unknown">Une erreur s\'est produite.</string>
|
||||
<string name="state_failure_permission">Un accès au Calendrier est nécessaire.</string>
|
||||
<string name="state_failure_permission_action">Autoriser l\'accès</string>
|
||||
<string name="state_failure_permission_action">Accorder l\'accès</string>
|
||||
<string name="state_failure_no_calendars">Aucun calendrier configuré.</string>
|
||||
<string name="state_failure_no_calendars_action">Ouvrir les paramètres du calendrier.</string>
|
||||
<string name="state_failure_provider">Ne peut pas lire le calendrier.</string>
|
||||
<string name="permission_rationale_title">Tous les évènements ont été trouvés.</string>
|
||||
<string name="permission_rationale_body">Calendula a besoin d\'avoir accès à votre calendrier pour afficher et accéder aux événements. C\'est tout ce dont nous avons besoin, aucune données ne sort de votre appareil.</string>
|
||||
<string name="state_failure_no_calendars_action">Ouvrir les paramètres systèmes du calendrier</string>
|
||||
<string name="state_failure_provider">Impossible de lire le calendrier.</string>
|
||||
<string name="permission_rationale_title">Accédez à tous vos évènements, en beauté</string>
|
||||
<string name="permission_rationale_body">Calendula a besoin d\'accéder à votre calendrier pour afficher et gérer vos évènements. C\'est tout ce qu\'il demande — et rien ne quitte votre appareil.</string>
|
||||
<string name="permission_request_button">Autoriser l\'accès au calendrier</string>
|
||||
<string name="permission_denied_title">Accès au calendrier refusé.</string>
|
||||
<string name="permission_denied_title">Accès au calendrier refusé</string>
|
||||
<string name="permission_denied_body">Calendula ne peut pas afficher les évènements si elle ne peut pas accéder au calendrier. Vous pouvez l\'autoriser à tout moment dans les paramètres.</string>
|
||||
<string name="permission_open_settings_button">Ouvrir les paramètres.</string>
|
||||
<string name="permission_open_settings_button">Ouvrir les paramètres système</string>
|
||||
<string name="permission_retry_button">Essayer à nouveau</string>
|
||||
<string name="permission_benefit_private_title">Reste sur votre appareil</string>
|
||||
<string name="permission_benefit_private_title">Ne quitte pas votre appareil</string>
|
||||
<string name="permission_benefit_private_body">Vos calendriers sont lus localement et ne quittent jamais le téléphone.</string>
|
||||
<string name="permission_benefit_sync_title">Tous vos calendriers, réunis</string>
|
||||
<string name="permission_benefit_privacy_title">Aucun suivi, jamais</string>
|
||||
<string name="permission_benefit_privacy_body">Aucune collecte de données, aucune analyse, pas de pubs</string>
|
||||
<string name="permission_benefit_privacy_body">Aucune collecte de données, aucune analyse, pas de pubs.</string>
|
||||
<string name="permission_privacy_footnote">Reste sur votre appareil - Pas d\'utilisation d\'Internet</string>
|
||||
<string name="month_prev">Mois précédent</string>
|
||||
<string name="month_next">Prochain mois</string>
|
||||
<string name="month_next">Mois suivant</string>
|
||||
<string name="month_today_action">Aujourd\'hui</string>
|
||||
<string name="month_more_actions">Plus d\'actions</string>
|
||||
<string name="month_open_menu">Ouvrir le menu</string>
|
||||
@@ -153,4 +153,351 @@
|
||||
<string name="event_attendee_needs_action">Aucune réponse</string>
|
||||
<string name="event_attendee_unknown">—</string>
|
||||
<string name="event_detail_reminders">Rappels</string>
|
||||
<string name="permission_benefit_sync_body">Google, CalDAV, local - Tout ce qui est synchronisé sur l\'appareil s\'affiche tout simplement.</string>
|
||||
<string name="event_status_cancelled">Annulé</string>
|
||||
<plurals name="duration_minutes">
|
||||
<item quantity="one">%d minute</item>
|
||||
<item quantity="many">%d minutes</item>
|
||||
<item quantity="other">%d minutes</item>
|
||||
</plurals>
|
||||
<string name="widget_new_event">Nouvel évènement</string>
|
||||
<string name="widget_next_month">Mois suivant</string>
|
||||
<string name="filter_title">Calendriers</string>
|
||||
<string name="agenda_range_custom">Personnaliser…</string>
|
||||
<string name="settings_section_calendars">Calendriers</string>
|
||||
<string name="calendars_title">Calendriers</string>
|
||||
<string name="calendars_backup_header">Sauvegarde</string>
|
||||
<string name="calendars_auto_backup_every">Chaque %1$s</string>
|
||||
<string name="backup_channel_name">Sauvegarde</string>
|
||||
<string name="shortcut_new_event_short">Nouvel évènement</string>
|
||||
<string name="qs_tile_new_event_label">Nouvel évènement</string>
|
||||
<string name="event_detail_duplicate">Dupliquer</string>
|
||||
<string name="event_detail_timezone">Fuseau horaire</string>
|
||||
<string name="event_availability_free">Disponible</string>
|
||||
<string name="event_access_private">Privé</string>
|
||||
<string name="event_access_confidential">Confidentiel</string>
|
||||
<string name="event_attendee_organizer">Organisateur</string>
|
||||
<string name="event_attendee_resource">Ressource</string>
|
||||
<string name="event_detail_self_response">Votre réponse : %1$s</string>
|
||||
<string name="reminder_default">Rappel par défaut</string>
|
||||
<plurals name="reminder_minutes">
|
||||
<item quantity="one">%d minute avant</item>
|
||||
<item quantity="many">%d minutes avant</item>
|
||||
<item quantity="other">%d minutes avant</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_hours">
|
||||
<item quantity="one">%d heure avant</item>
|
||||
<item quantity="many">%d heures avant</item>
|
||||
<item quantity="other">%d heures avant</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_days">
|
||||
<item quantity="one">%d jour avant</item>
|
||||
<item quantity="many">%d jours avant</item>
|
||||
<item quantity="other">%d jours avant</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_weeks">
|
||||
<item quantity="one">%d semaine avant</item>
|
||||
<item quantity="many">%d semaines avant</item>
|
||||
<item quantity="other">%d semaines avant</item>
|
||||
</plurals>
|
||||
<plurals name="duration_hours">
|
||||
<item quantity="one">%d heure</item>
|
||||
<item quantity="many">%d heures</item>
|
||||
<item quantity="other">%d heures</item>
|
||||
</plurals>
|
||||
<plurals name="duration_days">
|
||||
<item quantity="one">%d jour</item>
|
||||
<item quantity="many">%d jours</item>
|
||||
<item quantity="other">%d jours</item>
|
||||
</plurals>
|
||||
<plurals name="duration_weeks">
|
||||
<item quantity="one">%d semaine</item>
|
||||
<item quantity="many">%d semaines</item>
|
||||
<item quantity="other">%d semaines</item>
|
||||
</plurals>
|
||||
<string name="event_untitled">(Pas de titre)</string>
|
||||
<string name="reminder_channel_name">Rappels d\'évènement</string>
|
||||
<string name="reminder_channel_description">Notifications au moment des rappels de vos évènements</string>
|
||||
<string name="reminder_onboarding_title">Ne manquez jamais un évènement</string>
|
||||
<string name="reminder_benefit_delivery_title">Vos rappels, libérés</string>
|
||||
<string name="reminder_benefit_delivery_body">Chaque rappel à vos évènements vous délivre une notification, ponctuelle.</string>
|
||||
<string name="reminder_onboarding_enable_button">Activer les rappels</string>
|
||||
<string name="reminder_onboarding_skip_button">Plus tard</string>
|
||||
<string name="reminder_day_tomorrow">Demain</string>
|
||||
<string name="reminder_day_yesterday">Hier</string>
|
||||
<string name="view_month">Mois</string>
|
||||
<string name="view_week">Semaine</string>
|
||||
<string name="view_day">Jour</string>
|
||||
<string name="view_agenda">Agenda</string>
|
||||
<string name="view_section">Vue</string>
|
||||
<string name="drawer_jump_to_date">Aller à ...</string>
|
||||
<string name="agenda_header_tomorrow">Demain</string>
|
||||
<string name="agenda_empty_title">Vous êtes à jour</string>
|
||||
<string name="agenda_no_more_today">Plus d\'évènements aujourd\'hui</string>
|
||||
<string name="search_action">Rechercher</string>
|
||||
<string name="search_hint">Rechercher des évènements</string>
|
||||
<string name="search_idle_hint">Rechercher parmi vos évènements par titre, lieu, ou notes.</string>
|
||||
<string name="search_empty">Pas d\'évènements correspondant à “%1$s”.</string>
|
||||
<string name="widget_agenda_title">À venir</string>
|
||||
<string name="widget_agenda_label">Agenda Calendula</string>
|
||||
<string name="widget_month_label">Calendula Mois</string>
|
||||
<string name="widget_refresh">Actualiser</string>
|
||||
<string name="settings_section_appearance">Apparence</string>
|
||||
<string name="settings_theme">Thème</string>
|
||||
<string name="settings_theme_system">Système</string>
|
||||
<string name="settings_theme_light">Clair</string>
|
||||
<string name="settings_theme_dark">Sombre</string>
|
||||
<string name="settings_default_view">Vue par défaut</string>
|
||||
<string name="settings_dynamic_color">Couleurs dynamiques</string>
|
||||
<string name="settings_dynamic_color_unavailable">Nécessite Android 12 ou plus récent</string>
|
||||
<string name="settings_soften_colors">Adoucir les couleurs du calendrier</string>
|
||||
<string name="settings_soften_colors_summary">Ajuster les couleurs du calendrier et des évènements au thème. Désactiver pour voir les couleurs brutes issues du calendrier.</string>
|
||||
<string name="settings_font_headings">Police des titres</string>
|
||||
<string name="settings_font_body">Police du corps</string>
|
||||
<string name="settings_font_system">Valeur par défaut du système</string>
|
||||
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
|
||||
<string name="font_lora">Lora</string>
|
||||
<string name="settings_font_choose_file">Sélectionner un fichier…</string>
|
||||
<string name="settings_font_custom_selected">Police personnalisée</string>
|
||||
<string name="settings_font_import_failed">Impossible d\'interpréter ce fichier comme police</string>
|
||||
<string name="settings_week_start_auto">Automatique</string>
|
||||
<string name="settings_week_numbers">Numéros de semaine</string>
|
||||
<string name="settings_week_numbers_summary">Afficher les numéros de semaine dans la vue par mois</string>
|
||||
<string name="event_attendee_optional">Optionnel</string>
|
||||
<string name="agenda_today_action">Aujourd\'hui</string>
|
||||
<string name="agenda_header_today">Aujourd\'hui</string>
|
||||
<string name="search_back">Retour</string>
|
||||
<string name="widget_prev_month">Mois précédent</string>
|
||||
<string name="widget_today">Aujourd\'hui</string>
|
||||
<string name="settings_title">Paramètres</string>
|
||||
<string name="settings_back">Retour</string>
|
||||
<string name="agenda_range_day">Aujourd\'hui</string>
|
||||
<string name="agenda_range_this_week">Cette semaine</string>
|
||||
<string name="back">Retour</string>
|
||||
<string name="settings_special_dates_reminders">Rappels</string>
|
||||
<string name="settings_special_dates_grant">Autoriser l\'accès</string>
|
||||
<string name="dialog_save">Sauvegarder</string>
|
||||
<string name="calendars_color_label">Couleur</string>
|
||||
<string name="import_close">Fermer</string>
|
||||
<string name="week_number_label">Semaine</string>
|
||||
<string name="event_status_tentative">Provisoire</string>
|
||||
<string name="reminder_at_time">Au moment de l’événement</string>
|
||||
<string name="reminder_onboarding_body">Android 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_body">Si une autre application publie également des rappels, vous les verrez deux fois, désactivez-les là ou ici.</string>
|
||||
<string name="reminder_benefit_reversible_title">modifier à tout moment</string>
|
||||
<string name="reminder_benefit_reversible_body">Le commutateur se trouve dans les paramètres, sous Notifications.</string>
|
||||
<string name="reminder_action_snooze">Snooze</string>
|
||||
<string name="reminder_action_dismiss">Ignorer</string>
|
||||
<string name="search_clear">Effacer</string>
|
||||
<string name="widget_needs_permission">Ouvrez Calendula pour accorder l’accès au calendrier</string>
|
||||
<string name="font_jetbrains_mono">JetBrains Mono</string>
|
||||
<string name="settings_week_start">La semaine commence le</string>
|
||||
<string name="settings_time_format">Format de l\'heure</string>
|
||||
<string name="settings_time_format_auto">Automatique</string>
|
||||
<string name="settings_time_format_12h">12 heures (2:00 PM)</string>
|
||||
<string name="settings_time_format_24h">24 heures (14:00)</string>
|
||||
<string name="settings_hour_lines">Lignes d\'heures</string>
|
||||
<string name="settings_hour_lines_summary">Afficher une ligne de séparation à chaque heure dans la vue de la semaine et du jour</string>
|
||||
<string name="settings_dim_completed">Dim événements terminés</string>
|
||||
<string name="settings_dim_completed_summary">Estomper les événements qui se sont déjà terminés dans la vue du mois et de la semaine</string>
|
||||
<string name="settings_past_events">Evènements passés</string>
|
||||
<string name="settings_past_events_show">Montrer</string>
|
||||
<string name="settings_past_events_dim">Dim</string>
|
||||
<string name="settings_past_events_hide">Cacher</string>
|
||||
<string name="settings_agenda_header">Agenda</string>
|
||||
<string name="settings_agenda_range">Plage d’agenda</string>
|
||||
<string name="settings_agenda_range_hint">Jusqu’à quelle date l’écran Agenda liste les événements.</string>
|
||||
<string name="settings_agenda_widget_range">Plage de widgets Agenda</string>
|
||||
<string name="settings_agenda_widget_range_hint">Jusqu’à quelle heure le widget de l’écran d’accueil de l’agenda liste les événements.</string>
|
||||
<string name="settings_agenda_show_today">Toujours montrer aujourd’hui</string>
|
||||
<string name="settings_agenda_show_today_hint">Gardez aujourd’hui en tête de l’agenda et de son widget, même s’il ne reste plus rien aujourd’hui.</string>
|
||||
<string name="settings_agenda_range_bar">Barre de rang</string>
|
||||
<string name="settings_agenda_range_bar_hint">Afficher une barre en haut de l’agenda nommant les dates affichées, avec un bouton pour changer la plage de la session</string>
|
||||
<string name="agenda_range_this_month">Ce mois</string>
|
||||
<string name="agenda_range_week">Les 7 jours prochains</string>
|
||||
<string name="agenda_range_month">Les 30 jours prochains</string>
|
||||
<string name="agenda_range_custom_hint">Jours</string>
|
||||
<string name="agenda_range_override_hint">Juste pour l’instant — réinitialise votre plage d’enregistrement lorsque vous rouvrez Calendula.</string>
|
||||
<string name="agenda_range_showing_label">Afficher tous les événements à venir pour</string>
|
||||
<plurals name="agenda_range_days">
|
||||
<item quantity="one">%d jour</item>
|
||||
<item quantity="many">%d jours</item>
|
||||
</plurals>
|
||||
<string name="settings_section_views">Vues</string>
|
||||
<string name="settings_quick_switch_header">Bouton d’interrupteur rapide</string>
|
||||
<string name="settings_quick_switch_hint">Choisissez les vues que le bouton en haut à droite fait défiler, puis faites-les glisser pour les réorganiser. Les vues désactivées restent accessibles depuis le menu de navigation.</string>
|
||||
<string name="settings_drawer_order_header">Menu navigation</string>
|
||||
<string name="settings_drawer_order_hint">Faites glisser pour réorganiser les vues répertoriées dans le menu de navigation.</string>
|
||||
<string name="reorder_drag_handle">Faites glisser pour réorganiser</string>
|
||||
<string name="settings_section_event_form">Nouveau formulaire d’événement</string>
|
||||
<string name="settings_form_fields_hint">Champs affichés par défaut — tout le reste se trouve derrière « Plus de champs »</string>
|
||||
<string name="settings_autofocus_title">Titre principal du nouvel événement</string>
|
||||
<string name="settings_autofocus_title_hint">Lorsque vous démarrez un nouvel événement, placez le curseur dans le champ du titre et ouvrez immédiatement le clavier.</string>
|
||||
<string name="settings_color_unsupported">Autoriser les couleurs dans les calendriers non pris en charge</string>
|
||||
<string name="settings_color_unsupported_hint">Certains calendriers (par exemple, certains CalDAV) ne publient aucun ensemble de couleurs, une couleur d’événement personnalisée peut être supprimée ou remplacée lors de leur prochaine synchronisation. 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_default_reminder">rappel par défaut</string>
|
||||
<string name="settings_default_reminder_allday">événements d\'une journée entière</string>
|
||||
<string name="settings_allday_reminder_time">Heure de rappel toute la journée</string>
|
||||
<string name="settings_allday_reminder_time_hint">Les rappels pour les événements d’une journée entière se déclenchent à %1$s</string>
|
||||
<string name="reminder_none">Aucun</string>
|
||||
<string name="reminder_use_default">Utiliser le rappel par défaut</string>
|
||||
<string name="reminder_custom_amount">montant</string>
|
||||
<string name="reminder_custom_set">fixer</string>
|
||||
<string name="settings_calendar_reminders_title">Rappels par calendrier</string>
|
||||
<string name="settings_calendar_reminders_hint">Remplacer le paramètre par défaut par calendrier, séparément pour les événements planifiés et sur une journée. Un calendrier peut conserver le paramètre par défaut, l’abandonner ou définir le sien.</string>
|
||||
<string name="settings_calendar_reminder_inherits">Défaut (%1$s)</string>
|
||||
<string name="settings_reliable_delivery">livraison fiable</string>
|
||||
<string name="settings_reliable_delivery_hint">Android peut retarder les rappels pour économiser la batterie. Exempter Calendula afin qu’ils arrivent à temps.</string>
|
||||
<string name="settings_reliable_delivery_exempt">Exempté de l’optimisation de la batterie — les rappels arrivent à temps.</string>
|
||||
<string name="settings_snooze_duration">Durée de répétition</string>
|
||||
<string name="settings_manage_calendars">gérer les calendriers</string>
|
||||
<string name="settings_manage_calendars_hint">Créer des calendriers locaux, gérer les calendriers synchronisés</string>
|
||||
<string name="settings_section_language">Langue</string>
|
||||
<string name="settings_language">langue de l\'application</string>
|
||||
<string name="settings_language_auto">par défaut du système</string>
|
||||
<string name="settings_translate">aider à traduire</string>
|
||||
<string name="settings_translate_hint">Ajouter ou améliorer une langue sur Weblate</string>
|
||||
<string name="settings_appearance_subtitle">Thème, vue par défaut, début de la semaine</string>
|
||||
<string name="settings_views_subtitle">Bouton de sélection rapide et ordre du menu</string>
|
||||
<string name="settings_event_form_subtitle">Champs par défaut pour les nouveaux événements</string>
|
||||
<string name="settings_notifications_subtitle">rappels d\'événement</string>
|
||||
<string name="settings_special_dates_subtitle">Anniversaires et dates de naissance des contacts</string>
|
||||
<string name="settings_section_special_dates">Contact, dates spéciales</string>
|
||||
<string name="settings_special_dates_enable">Afficher les dates de contact</string>
|
||||
<string name="settings_special_dates_enable_hint">Reprendre les anniversaires et autres dates de vos contacts dans des calendriers locaux. Lit les contacts uniquement sur cet appareil — rien n’est téléchargé et vos contacts ne sont jamais modifiés.</string>
|
||||
<string name="settings_special_dates_type_birthday">Anniversaires</string>
|
||||
<string name="settings_special_dates_type_anniversary">Anniversaires</string>
|
||||
<string name="settings_special_dates_type_custom">Autres dates</string>
|
||||
<string name="settings_special_dates_template">Format du titre</string>
|
||||
<string name="settings_special_dates_template_hint">Utilisez {name} pour le contact et {year} pour l’année (l’année de naissance ou l’année de début d’un anniversaire, masqué si inconnu).</string>
|
||||
<string name="settings_special_dates_show_year">Montrer l\'année</string>
|
||||
<string name="settings_special_dates_show_year_hint">Inclure {year} dans les titres lorsque l\'année est connue</string>
|
||||
<string name="settings_special_dates_sync_now">Synchroniser maintenant</string>
|
||||
<string name="settings_special_dates_never_synced">Pas encore synchronisé</string>
|
||||
<string name="settings_special_dates_last_synced">Dernière synchronisation %1$s</string>
|
||||
<string name="settings_special_dates_calendar_hint">Définissez la couleur et la visibilité de chaque calendrier dans les paramètres des calendriers.</string>
|
||||
<string name="settings_calendar_reminders_managed_hint">Définir dans les dates spéciales de contact</string>
|
||||
<string name="settings_special_dates_paused_title">suspendu</string>
|
||||
<string name="settings_special_dates_paused_hint">Calendula ne peut plus lire vos contacts, donc ces calendriers ne se mettent pas à jour.</string>
|
||||
<string name="settings_special_dates_disable_title">Désactiver les dates de contact ?</string>
|
||||
<string name="settings_special_dates_disable_all_message">Cela supprime les calendriers de contact et leurs événements. Tous les rappels ou notes que vous leur avez ajoutés seront perdus.</string>
|
||||
<string name="settings_special_dates_disable_type_message">Cela supprime le calendrier « %1$s » et ses événements. Tous les rappels ou notes que vous y avez ajoutés seront perdus.</string>
|
||||
<string name="settings_special_dates_disable_confirm">désactiver</string>
|
||||
<string name="settings_section_about">A propos</string>
|
||||
<string name="settings_license">Licence</string>
|
||||
<string name="settings_license_value">MIT</string>
|
||||
<string name="settings_about_author">par Jean-Luc Makiola</string>
|
||||
<string name="settings_about_source">Source</string>
|
||||
<string name="settings_about_support">soutenir le développement</string>
|
||||
<string name="settings_about_version">Version %1$s</string>
|
||||
<string name="settings_about_logo_desc">Icône de l’application Calendula</string>
|
||||
<string name="settings_report_problem">Rapporter un problème</string>
|
||||
<string name="settings_report_problem_hint">Envoyer un rapport de plantage ou ouvrir le suivi des problèmes</string>
|
||||
<string name="calendars_local_header">Vos calendriers</string>
|
||||
<string name="calendars_local_empty">Pas encore de calendrier local. Créez-en un pour conserver les événements uniquement sur cet appareil.</string>
|
||||
<string name="calendars_add">Ajouter un calendrier</string>
|
||||
<string name="calendars_disable_hint">Désactivez un calendrier pour le retirer de l’application, ses événements, ses filtres et ses sélecteurs. Rien n’est supprimé et vous pouvez le réactiver à tout moment ici.</string>
|
||||
<string name="calendars_show_in_app_a11y">Afficher « %1$s » dans l’application</string>
|
||||
<string name="calendars_synced_header">Calendriers synchronisés</string>
|
||||
<string name="calendars_synced_hint">Ils proviennent de comptes sur votre appareil. Créez et modifiez-les dans leur propre application.</string>
|
||||
<string name="calendars_manage_in_app">Gérer dans l’application</string>
|
||||
<string name="calendars_account_menu_a11y">Plus d’options pour %1$s</string>
|
||||
<string name="calendars_enable_all">Tout activer</string>
|
||||
<string name="calendars_disable_all">Tout désactiver</string>
|
||||
<string name="calendars_add_account">Ajouter un compte</string>
|
||||
<string name="calendars_new_title">Nouveau calendrier</string>
|
||||
<string name="calendars_edit_title">Editer un agenda</string>
|
||||
<string name="calendars_name_label">Nom</string>
|
||||
<string name="calendars_description_hint">Ajouter une description</string>
|
||||
<string name="calendars_delete_confirm_title">Supprimer un calendrier ?</string>
|
||||
<string name="calendars_delete_confirm_message">\"%1$s\" et tous ses événements seront définitivement retirés de cet appareil.</string>
|
||||
<string name="calendars_write_error">Impossible de sauvegarder les changements.</string>
|
||||
<string name="calendars_backup_hint">Les calendriers locaux ne sont synchronisés nulle part, alors exportez-les vers un fichier .ics pour en garder une copie.</string>
|
||||
<string name="calendars_backup_action">Exporter en tant que fichier .ics</string>
|
||||
<string name="calendars_export_title">Exporter des calendriers</string>
|
||||
<string name="calendars_export_hint">Choisissez les calendriers à inclure dans le fichier .ics.</string>
|
||||
<string name="calendars_export_action">Exporter</string>
|
||||
<string name="calendars_restore_header">Restaurer</string>
|
||||
<string name="calendars_restore_action">Restaurer à partir du fichier .ics</string>
|
||||
<string name="calendars_restore_hint">Importez des événements à partir d’une sauvegarde ou d’une autre application de calendrier.</string>
|
||||
<string name="calendars_auto_backup">sauvegarde automatique</string>
|
||||
<string name="calendars_auto_backup_hint">Exportez périodiquement vos calendriers locaux dans un dossier en tant que fichier .ics.</string>
|
||||
<string name="calendars_auto_backup_folder">dossier de sauvegarde</string>
|
||||
<string name="calendars_auto_backup_folder_unset">Cliquez pour choisir un dossier</string>
|
||||
<string name="calendars_auto_backup_interval">Intervalle</string>
|
||||
<string name="calendars_auto_backup_interval_min">Minimum 30 minutes.</string>
|
||||
<string name="calendars_auto_backup_status_never">Pas encore de sauvegarde automatique</string>
|
||||
<string name="calendars_auto_backup_status_ok">Dernière sauvegarde : %1$s</string>
|
||||
<string name="calendars_auto_backup_status_failed">La dernière sauvegarde a échoué : %1$s</string>
|
||||
<string name="backup_channel_description">Avertit si les sauvegardes automatiques échouent de manière répétée.</string>
|
||||
<string name="backup_failed_title">La sauvegarde automatique a échoué</string>
|
||||
<string name="backup_failed_text">Calendula n’a pas pu écrire le fichier de sauvegarde. Vérifiez le dossier de sauvegarde dans les paramètres.</string>
|
||||
<string name="calendars_backup_failed">Impossible d’exporter la sauvegarde.</string>
|
||||
<plurals name="calendars_backup_done">
|
||||
<item quantity="one">Événement %d exporté.</item>
|
||||
<item quantity="many">Événements %d exportés.</item>
|
||||
<item quantity="other">Événements %d exportés.</item>
|
||||
</plurals>
|
||||
<string name="import_title">importer des événements</string>
|
||||
<string name="import_target_header">ajouter au calendrier</string>
|
||||
<string name="import_empty">Aucun événement trouvé dans ce fichier.</string>
|
||||
<string name="import_failed">Impossible de lire ce fichier.</string>
|
||||
<string name="import_no_calendar">Pas de calendrier lisible à importer. Créez d’abord un calendrier local.</string>
|
||||
<string name="import_done_title">Importation terminée</string>
|
||||
<string name="import_done_dedup_note">Les événements déjà présents dans le calendrier ont été ignorés.</string>
|
||||
<string name="import_done_added_label">ajouté</string>
|
||||
<string name="import_done_skipped_label">Dupliquer</string>
|
||||
<string name="import_warning_recurrence">Certaines occurrences modifiées d’événements récurrents ont été ignorées.</string>
|
||||
<string name="import_warning_no_start">Un événement sans heure de début a été ignoré.</string>
|
||||
<string name="import_warning_attendees">Les listes d’invités n’ont pas été importées.</string>
|
||||
<string name="import_warning_timezone">Un fuseau horaire inconnu est revenu sur celui de votre appareil.</string>
|
||||
<string name="import_button">Import</string>
|
||||
<plurals name="import_title_count">
|
||||
<item quantity="one">Importation de l’événement %d</item>
|
||||
<item quantity="many">Importation des événements %d</item>
|
||||
<item quantity="other">Importation des événements %d</item>
|
||||
</plurals>
|
||||
<plurals name="import_event_count">
|
||||
<item quantity="one">%d événement dans ce fichier.</item>
|
||||
<item quantity="many">%d événements dans ce fichier.</item>
|
||||
<item quantity="other">%d événements dans ce fichier.</item>
|
||||
</plurals>
|
||||
<plurals name="import_action">
|
||||
<item quantity="one">Importer l’événement %d</item>
|
||||
<item quantity="many">Importer les événements %d</item>
|
||||
<item quantity="other">Importer les événements %d</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_imported">
|
||||
<item quantity="one">Événement %d importé.</item>
|
||||
<item quantity="many">Événements %d importés.</item>
|
||||
<item quantity="other">Événements %d importés.</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_skipped">
|
||||
<item quantity="one">Ignoré %d déjà dans ce calendrier.</item>
|
||||
<item quantity="many">Ignorés %d déjà dans ce calendrier.</item>
|
||||
<item quantity="other">Ignorés %d déjà dans ce calendrier.</item>
|
||||
</plurals>
|
||||
<string name="shortcut_new_event_long">créer un événement</string>
|
||||
<string name="settings_qs_tile">Ajouter des paramètres rapides</string>
|
||||
<string name="settings_qs_tile_hint">Ajoutez un bouton « Nouvel événement » au panneau Paramètres rapides.</string>
|
||||
<string name="crash_dialog_title">%1$s a planté</string>
|
||||
<string name="crash_dialog_message">%1$s a été fermé de manière inattendue la dernière fois. Vous pouvez aider à le corriger en envoyant ce rapport en tant que problème. Il reste sur votre appareil jusqu’à ce que vous choisissiez de le partager, et 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>
|
||||
<string name="crash_dialog_dismiss">pas maintenant</string>
|
||||
<string name="crash_report_issue_title">rapport de plantage</string>
|
||||
<string name="crash_report_clip_label">%1$s rapport de plantage</string>
|
||||
<string name="crash_report_copied">Rapport copié dans votre presse-papiers</string>
|
||||
<string name="crash_report_open_failed">Impossible d’ouvrir le suivi des problèmes. Le rapport se trouve dans votre presse-papiers.</string>
|
||||
<string name="crash_report_body_template">Merci d’avoir signalé un plantage dans %1$s. Veuillez ajouter tout ce dont vous vous souvenez sur ce que vous faisiez, puis soumettez-le.\n\n### Que s’est-il passé\n\n\n### Rapport de plantage\n%2$s\n</string>
|
||||
<string name="crash_report_body_paste">_(Le rapport était trop long pour ce lien — collez-le depuis votre presse-papiers ici.)_</string>
|
||||
<string name="special_dates_calendar_birthday">anniversaires</string>
|
||||
<string name="special_dates_calendar_anniversary">Anniversaires</string>
|
||||
<string name="special_dates_calendar_custom">dates spéciales</string>
|
||||
<string name="special_dates_default_title_birthday">{name}\'s birthday ({year})</string>
|
||||
<string name="special_dates_default_title_anniversary">{name}, anniversaire ({year})</string>
|
||||
<string name="special_dates_default_title_custom">{name}</string>
|
||||
</resources>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<string name="state_failure_no_calendars_action">Apri le impostazioni calendario di sistema</string>
|
||||
<string name="state_failure_provider">Errore nella lettura del calendario.</string>
|
||||
<string name="permission_rationale_title">Gestisci magnificamente tutti i tuoi eventi</string>
|
||||
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa di cui ha bisogno e nessuna informazione lascerà mai il tuo dispositivo.</string>
|
||||
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa di cui ha bisogno: nessuna informazione lascerà mai il tuo dispositivo.</string>
|
||||
<string name="permission_request_button">Concedi l\'accesso al calendario</string>
|
||||
<string name="permission_denied_title">Accesso al calendario negato</string>
|
||||
<string name="permission_denied_body">Calendula non può mostrare gli eventi senza accesso al calendario. Puoi concederlo dalle impostazioni di sistema.</string>
|
||||
@@ -228,7 +228,7 @@
|
||||
<string name="widget_new_event">Nuovo evento</string>
|
||||
<string name="widget_needs_permission">Apri Calendula per concedere l\'accesso al calendario</string>
|
||||
<string name="widget_prev_month">Mese precedente</string>
|
||||
<string name="filter_title">Calendari</string>
|
||||
<string name="filter_title">Calendario</string>
|
||||
<string name="settings_section_appearance">Aspetto</string>
|
||||
<string name="settings_theme">Tema</string>
|
||||
<string name="settings_theme_system">Sistema</string>
|
||||
@@ -253,18 +253,18 @@
|
||||
<string name="settings_past_events_hide">Nascondi</string>
|
||||
<string name="settings_agenda_header">Agenda</string>
|
||||
<string name="settings_agenda_range">Intervallo dell\'Agenda</string>
|
||||
<string name="settings_agenda_range_hint">Anticipo con cui vengono visualizzati gli eventi nell\'Agenda.</string>
|
||||
<string name="settings_agenda_widget_range">Intervallo del widget Agenda</string>
|
||||
<string name="settings_agenda_widget_range_hint">Anticipo con cui vengono visualizzati gli eventi nel widget Agenda.</string>
|
||||
<string name="settings_agenda_range_hint">Intervallo di visualizzazione degli eventi nell\'Agenda.</string>
|
||||
<string name="settings_agenda_widget_range">Intervallo di visualizzazione degli eventi nel widget Agenda</string>
|
||||
<string name="settings_agenda_widget_range_hint">Intervallo di visualizzazione degli eventi nel widget Agenda.</string>
|
||||
<string name="settings_agenda_range_bar">Barra dell\'intervallo</string>
|
||||
<string name="settings_agenda_range_bar_hint">Mostra una barra sopra l\'agenda che indica le date mostrate, con un pulsante per cambiare l\'intervallo scelto per la sessione</string>
|
||||
<string name="settings_agenda_range_bar_hint">Nella vista Agenda, mostra una barra che indica l\'intervallo di date visualizzato, con un pulsante per cambiarlo</string>
|
||||
<string name="agenda_range_day">Oggi</string>
|
||||
<string name="agenda_range_this_month">Questo mese</string>
|
||||
<string name="agenda_range_week">Prossimi 7 giorni</string>
|
||||
<string name="agenda_range_month">Prossimi 30 giorni</string>
|
||||
<string name="agenda_range_custom">Personalizza…</string>
|
||||
<string name="agenda_range_custom_hint">Giorni</string>
|
||||
<string name="agenda_range_override_hint">Solo questa volta, ripristina l\'intervallo salvato al riavvio di Calendula.</string>
|
||||
<string name="agenda_range_override_hint">L\'intervallo viene modificato solo questa volta. La prossima volta che apri Calendula verrà visualizzato l\'intervallo di default.</string>
|
||||
<string name="agenda_range_showing_label">Mostra tutti gli eventi in arrivo per</string>
|
||||
<plurals name="agenda_range_days">
|
||||
<item quantity="one">%d giorno</item>
|
||||
@@ -272,26 +272,26 @@
|
||||
<item quantity="other">%d giorni</item>
|
||||
</plurals>
|
||||
<string name="settings_section_event_form">Scheda Nuovo Evento</string>
|
||||
<string name="settings_form_fields_hint">Campi mostrati di default, tutti gli altri saranno in \"Altri campi\"</string>
|
||||
<string name="settings_autofocus_title">Subito al titolo su un nuovo evento</string>
|
||||
<string name="settings_autofocus_title_hint">Quando crei un nuovo evento, posiziona subito il cursore sul campo Titolo e mostra la tastiera.</string>
|
||||
<string name="settings_color_unsupported">Abilita i colori sui calendari non supportati</string>
|
||||
<string name="settings_color_unsupported_hint">Alcuni calendari (e.g. alcuni CalDAV) non pubblicano il set di colori di cui sono dotati; i colori personalizzati assegnati a un evento possono essere persi o sovrascritti alla prossima sincronizzazione. È una limitazione di questo tipo di calendari, Calendula non può farci nulla.</string>
|
||||
<string name="settings_section_notifications">Notifiche</string>
|
||||
<string name="settings_form_fields_hint">Campi di default, tutti gli altri saranno in \"Altri campi\"</string>
|
||||
<string name="settings_autofocus_title">Focus sul titolo</string>
|
||||
<string name="settings_autofocus_title_hint">Quando crei un nuovo evento posiziona subito il cursore sul campo Titolo e mostra la tastiera.</string>
|
||||
<string name="settings_color_unsupported">Abilita i colori sui calendari anche sui calendari che non li supportano</string>
|
||||
<string name="settings_color_unsupported_hint">Alcuni calendari (e.g. alcuni CalDAV) non comunicano il set di colori di cui sono dotati; se assegni colori personalizzati agli eventi può succedere che vengano persi o sovrascritti alla prossima sincronizzazione. È una limitazione di questo tipo di calendari, Calendula non può farci nulla.</string>
|
||||
<string name="settings_section_notifications">Promemoria</string>
|
||||
<string name="settings_reminders_hint">Vedi promemoria duplicati? Vuol dire che li sta mostrando anche un\'altra app. Disattivali qui o nell\'altra app.</string>
|
||||
<string name="settings_default_reminder_allday">Eventi giornalieri</string>
|
||||
<string name="settings_allday_reminder_time">Ora per i promemoria degli eventi giornalieri</string>
|
||||
<string name="settings_allday_reminder_time_hint">I promemoria per gli eventi giornalieri vengono mandati alle %1$s</string>
|
||||
<string name="settings_default_reminder_allday">Promemoria per eventi giornalieri</string>
|
||||
<string name="settings_allday_reminder_time">Orario di invio dei promemoria degli eventi giornalieri</string>
|
||||
<string name="settings_allday_reminder_time_hint">I promemoria per gli eventi giornalieri vengono inviati alle %1$s</string>
|
||||
<string name="reminder_none">Nessuno</string>
|
||||
<string name="reminder_use_default">Usa il promemoria standard</string>
|
||||
<string name="reminder_use_default">Promemoria standard</string>
|
||||
<string name="reminder_custom_amount">Valore</string>
|
||||
<string name="reminder_custom_set">Imposta</string>
|
||||
<string name="settings_calendar_reminders_title">Promemoria per calendario</string>
|
||||
<string name="settings_calendar_reminders_hint">Sovrascrivi i reminder standard con reminder specifici per calendario, separati per eventi giornalieri ed eventi ad ore. Ogni calendario può usare i promemoria di default, ignorarli o avere i suoi personalizzati.</string>
|
||||
<string name="settings_calendar_reminders_hint">Sostituisci i promemoria standard con promemoria specifici per ogni calendario, dettagliando quelli per eventi standard ed quelli per eventi giornalieri. Ogni calendario può usare i promemoria di default, ignorarli o avere i suoi personalizzati.</string>
|
||||
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
|
||||
<string name="settings_reliable_delivery">Notifiche affidabili</string>
|
||||
<string name="settings_reliable_delivery_hint">Android potrebbe posticipare le notifiche per risparmiare batteria. Escludi Calendula così che le notifiche possano arrivare puntuali.</string>
|
||||
<string name="settings_reliable_delivery_exempt">Escludi dall\'ottimizzazione batteria. I promemoria arriveranno puntuali.</string>
|
||||
<string name="settings_reliable_delivery_hint">Android potrebbe posticipare le notifiche per migliorare la durata batteria. Escludi Calendula dall\'ottimizzazione della batteria per far arrivare puntuali i promemoria.</string>
|
||||
<string name="settings_reliable_delivery_exempt">I promemoria arriveranno puntuali.</string>
|
||||
<string name="settings_snooze_duration">Durata del posticipo</string>
|
||||
<string name="settings_section_calendars">Calendari</string>
|
||||
<string name="settings_manage_calendars">Gestisci calendari</string>
|
||||
@@ -314,17 +314,17 @@
|
||||
<string name="settings_report_problem">Segnala un problema</string>
|
||||
<string name="settings_report_problem_hint">Invia un crash report o apri il gestore dei problemi</string>
|
||||
<string name="calendars_title">Calendari</string>
|
||||
<string name="calendars_local_header">I tuoi calendari</string>
|
||||
<string name="calendars_local_empty">Non ci sono ancora calendari locali. Creane uno per avere gli eventi solo su questo dispositivo.</string>
|
||||
<string name="calendars_local_header">Calendari locali</string>
|
||||
<string name="calendars_local_empty">Non ci sono ancora calendari locali. Creane uno con eventi salvati solo su questo dispositivo.</string>
|
||||
<string name="calendars_add">Aggiungi calendario</string>
|
||||
<string name="calendars_disable_hint">.Disattiva un calendario per non vederlo più sull\'app. Non viene cancellato nulla, puoi riattivarlo in qualsiasi momento.</string>
|
||||
<string name="calendars_disable_hint">Disattiva un calendario per non vederlo più sull\'app. Non viene cancellato nulla, puoi riattivarlo in qualsiasi momento.</string>
|
||||
<string name="calendars_show_in_app_a11y">Mostra \"%1$s\" nell\'app</string>
|
||||
<string name="calendars_synced_header">Calendari sincronizzati</string>
|
||||
<string name="calendars_synced_hint">Questi vengono dagli account presenti sul tuo dispositivo. Creali e modificali nella loro app.</string>
|
||||
<string name="calendars_synced_hint">Questi calendari vengono dagli account sincronizzati sul tuo dispositivo. Puoi modificarli o crearne di nuovi dall\'app di sincronizzazione.</string>
|
||||
<string name="calendars_manage_in_app">Gestisci in app</string>
|
||||
<string name="calendars_account_menu_a11y">Altre opzioni per %1$s</string>
|
||||
<string name="calendars_enable_all">Attiva tutti</string>
|
||||
<string name="calendars_disable_all">Disattiva tutti</string>
|
||||
<string name="calendars_enable_all">Mostra tutti</string>
|
||||
<string name="calendars_disable_all">Nascondi tutti</string>
|
||||
<string name="calendars_add_account">Aggiungi account</string>
|
||||
<string name="calendars_new_title">Nuovo calendario</string>
|
||||
<string name="calendars_edit_title">Modifica calendario</string>
|
||||
@@ -350,7 +350,7 @@
|
||||
<string name="backup_channel_name">Backup</string>
|
||||
<string name="backup_channel_description">Avvisa se i backup automatici falliscono ripetutamente.</string>
|
||||
<string name="backup_failed_title">Backup automatico fallito</string>
|
||||
<string name="backup_failed_text">Calendula non è riuscito a esportare il file di backup. Controlla la cartella di backup nelle Impostazioni.</string>
|
||||
<string name="backup_failed_text">Calendula non è riuscito a esportare il file di backup. Verifica quale sia la cartella di backup nelle Impostazioni.</string>
|
||||
<string name="calendars_backup_failed">Impossibile esportare il backup.</string>
|
||||
<plurals name="calendars_backup_done">
|
||||
<item quantity="one">Esportato %d evento.</item>
|
||||
@@ -390,17 +390,17 @@
|
||||
</plurals>
|
||||
<string name="shortcut_new_event_long">Crea un nuovo evento</string>
|
||||
<string name="qs_tile_new_event_label">Nuovo evento</string>
|
||||
<string name="settings_qs_tile">Aggiungi un toggle nelle Impostazioni Rapide</string>
|
||||
<string name="settings_qs_tile_hint">Aggiungi un toggle \"Nuovo evento\" nel pannello delle Impostazioni Rapide.</string>
|
||||
<string name="crash_dialog_title">Calendula è crashato</string>
|
||||
<string name="crash_dialog_message">Calendula si è chiuso inaspettatamente l\'ultima volta. Puoi contribuire allo sviluppo inviando questo report, che rimane sul tuo dispositivo finchè non scegli di inviarlo e non include informazioni personali o sui tuoi calendari:solo le informazioni tecniche qui sotto.</string>
|
||||
<string name="settings_qs_tile">Aggiungi un riquadro nelle Impostazioni Rapide</string>
|
||||
<string name="settings_qs_tile_hint">Aggiungi un riquadro \"Nuovo evento\" nel pannello delle Impostazioni Rapide.</string>
|
||||
<string name="crash_dialog_title">%1$s crashato</string>
|
||||
<string name="crash_dialog_message">%1$s si è chiuso inaspettatamente l\'ultima volta. Puoi contribuire allo sviluppo inviando questo report, che rimane sul tuo dispositivo finchè non scegli di inviarlo e non include informazioni personali o sui tuoi calendari: solo le informazioni tecniche qui sotto.</string>
|
||||
<string name="crash_dialog_report">Report</string>
|
||||
<string name="crash_dialog_dismiss">Non ora</string>
|
||||
<string name="crash_report_issue_title">Crash report</string>
|
||||
<string name="crash_report_clip_label">Calendula crash report</string>
|
||||
<string name="crash_report_clip_label">%1$s crash report</string>
|
||||
<string name="crash_report_copied">Report copiato negli Appunti</string>
|
||||
<string name="crash_report_open_failed">Impossibile aprire il gestore dei problemi. Il report è sugli Appunti.</string>
|
||||
<string name="crash_report_body_template">Grazie per riportare un crash di Calendula. Per favore, aggiungi ogni informazione che ricordi in merito a quello che stavi facendo, quindi invia\n\n### Cosa è successo\n\n\n###Crash report\n%1$s\n</string>
|
||||
<string name="crash_report_open_failed">Impossibile aprire il gestore dei problemi. Il report è negli Appunti.</string>
|
||||
<string name="crash_report_body_template">Grazie per segnalare un crash di %1$s. Per favore, aggiungi ogni informazione che ricordi in merito a quello che stavi facendo, quindi invia\n\n### Cosa è successo\n\n\n###Crash report\n%2$s\n</string>
|
||||
<string name="crash_report_body_paste">_(Il report era troppo lungo per questo link - copialo qui dai tuoi Appunti.)_</string>
|
||||
<string name="event_detail_recurrence">Ricorrenza</string>
|
||||
<string name="event_detail_recurring">Evento ricorrente</string>
|
||||
@@ -414,14 +414,14 @@
|
||||
<string name="settings_title">Impostazioni</string>
|
||||
<string name="settings_back">Indietro</string>
|
||||
<string name="agenda_range_this_week">Questa settimana</string>
|
||||
<string name="settings_reminders">Promemoria evento</string>
|
||||
<string name="settings_default_reminder">Promemoria standard</string>
|
||||
<string name="settings_reminders">Promemoria per gli eventi</string>
|
||||
<string name="settings_default_reminder">Promemoria per eventi standard</string>
|
||||
<string name="settings_notifications_subtitle">Promemoria evento</string>
|
||||
<string name="shortcut_new_event_short">Nuovo evento</string>
|
||||
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente.</string>
|
||||
<string name="settings_font_headings">Carattere del titolo</string>
|
||||
<string name="settings_font_body">Carattere del corpo</string>
|
||||
<string name="settings_font_system">Predefinito di sistema</string>
|
||||
<string name="settings_font_system">Lingua di sistema</string>
|
||||
<string name="settings_font_choose_file">Scegli file…</string>
|
||||
<string name="settings_font_custom_selected">Font personalizzato</string>
|
||||
<string name="settings_font_import_failed">Impossibile leggere il file come font</string>
|
||||
@@ -446,11 +446,11 @@
|
||||
<string name="settings_special_dates_show_year_hint">Include {year} nei titoli quando è noto</string>
|
||||
<string name="settings_special_dates_sync_now">Sincronizza adesso</string>
|
||||
<string name="settings_special_dates_never_synced">Ancora non sincronizzato</string>
|
||||
<string name="settings_special_dates_last_synced">%1$s è un tempo relativo, per esempio \"5 minuti fa\"</string>
|
||||
<string name="settings_special_dates_last_synced">Ultima sincronizzazione %1$s</string>
|
||||
<string name="settings_special_dates_calendar_hint">Imposta colore e visibilità di ogni calendario nelle impostazioni dei calendari.</string>
|
||||
<string name="settings_special_dates_paused_title">In pausa</string>
|
||||
<string name="settings_special_dates_paused_hint">Calendula non può più leggere i tuoi contatti, quindi questi calendari non vengono aggiornati.</string>
|
||||
<string name="settings_special_dates_grant">Concedi accesso</string>
|
||||
<string name="settings_special_dates_grant">Concedi l\'accesso</string>
|
||||
<string name="settings_special_dates_disable_title">Disattivare date dei contatti?</string>
|
||||
<string name="settings_special_dates_disable_all_message">Questo cancella i calendari delle date dei contatti ed i loro eventi. Ogni promemoria o note che erano stati aggiunti saranno persi.</string>
|
||||
<string name="settings_special_dates_disable_type_message">Questo cancella il calendario “%1$s” ed i suoi eventi. Ogni promemoria o note che erano stati aggiunti saranno persi.</string>
|
||||
@@ -466,4 +466,39 @@
|
||||
<string name="font_lora">Lora</string>
|
||||
<string name="font_jetbrains_mono">JetBrains Mono</string>
|
||||
<string name="special_dates_default_title_custom">{name}</string>
|
||||
<string name="event_detail_duplicate">Duplica</string>
|
||||
<string name="import_reminder_prompt_title">Vuoi applicare i promemoria di default?</string>
|
||||
<string name="import_reminder_prompt_body_none">Questo evento è stato importato senza nessun promemoria.</string>
|
||||
<plurals name="import_reminder_prompt_body_existing">
|
||||
<item quantity="one">Questo evento è stato importato con %1$d promemoria.</item>
|
||||
<item quantity="many">Questo evento è stato importato con %1$d promemoria.</item>
|
||||
<item quantity="other">Questo evento è stato importato con %1$d promemoria.</item>
|
||||
</plurals>
|
||||
<string name="import_reminder_prompt_apply">Applica promemoria di default</string>
|
||||
<string name="import_reminder_prompt_keep">Lascia invariato</string>
|
||||
<string name="reminder_day_tomorrow">Domani</string>
|
||||
<string name="reminder_day_yesterday">Ieri</string>
|
||||
<string name="agenda_no_more_today">Nessun altro evento per oggi</string>
|
||||
<string name="back">Indietro</string>
|
||||
<string name="settings_soften_colors">Attenua i colori del calendario</string>
|
||||
<string name="settings_soften_colors_summary">I colori dei calendari e degli eventi vengono attenuati per adattarsi al tema. Deseleziona per mostrare i colori naturali.</string>
|
||||
<string name="settings_week_numbers">Numeri di settimana</string>
|
||||
<string name="settings_week_numbers_summary">Mostra i numeri di settimana nella vista Mese</string>
|
||||
<string name="settings_agenda_show_today">Mostra sempre Oggi</string>
|
||||
<string name="settings_agenda_show_today_hint">Mantieni Oggi in alto nell\'Agenda e nel widget, anche se non ci sono altri eventi in giornata.</string>
|
||||
<string name="calendars_export_title">Esporta calendari</string>
|
||||
<string name="calendars_export_hint">Scegli quali calendari includere nel file .ics.</string>
|
||||
<string name="calendars_export_action">Esporta</string>
|
||||
<string name="calendars_restore_header">Ripristina</string>
|
||||
<string name="calendars_restore_action">Ripristina da un file .ics</string>
|
||||
<string name="calendars_restore_hint">Importa eventi da un backup o da un\'altra app.</string>
|
||||
<string name="import_done_dedup_note">Gli eventi già presenti nel calendario sono stati saltati.</string>
|
||||
<string name="import_done_added_label">Aggiunto</string>
|
||||
<string name="import_done_skipped_label">Duplicati</string>
|
||||
<string name="import_button">Importa</string>
|
||||
<plurals name="import_title_count">
|
||||
<item quantity="one">Importando %d evento</item>
|
||||
<item quantity="many">Importando %d eventi</item>
|
||||
<item quantity="other">Importando %d eventi</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
|
||||
@@ -47,11 +47,11 @@
|
||||
<string name="event_share_failed">Nie udało się udostępnić tego wydarzenia.</string>
|
||||
<string name="event_delete_title">Usunąć wydarzenie?</string>
|
||||
<string name="event_delete_body">Usunięto wydarzenie z Twojego kalendarza i wszystkich zsynchronizowanych urządzeń.</string>
|
||||
<string name="event_delete_recurring_title">Usuń powtarzające się wydarzenie</string>
|
||||
<string name="event_delete_recurring_title">Usuń wydarzenie cykliczne</string>
|
||||
<string name="event_delete_option_occurrence">Tylko to wystąpienie</string>
|
||||
<string name="event_delete_option_following">To i wszystkie następne wystąpienia</string>
|
||||
<string name="event_delete_option_series">Wszystkie wystąpienia w serii</string>
|
||||
<string name="event_edit_recurring_title">Edytuj powtarzające się wydarzenie</string>
|
||||
<string name="event_edit_recurring_title">Edytuj wydarzenie cykliczne</string>
|
||||
<string name="event_delete_failed">Nie udało się usunąć tego wydarzenia</string>
|
||||
<string name="event_delete_write_denied">Calendula wymaga uprawnień do zapisu, aby móc usuwać wydarzenia</string>
|
||||
<string name="dialog_cancel">Anuluj</string>
|
||||
@@ -136,7 +136,7 @@
|
||||
<string name="event_detail_description">Opis</string>
|
||||
<string name="event_detail_attendees">Uczestnicy</string>
|
||||
<string name="event_detail_recurrence">Powtarzanie</string>
|
||||
<string name="event_detail_recurring">Powtarzające się wydarzenie</string>
|
||||
<string name="event_detail_recurring">Wydarzenie cykliczne</string>
|
||||
<string name="recurrence_daily">Codziennie</string>
|
||||
<string name="recurrence_weekly">Co tydzień</string>
|
||||
<string name="recurrence_monthly">Co miesiąc</string>
|
||||
@@ -297,4 +297,224 @@
|
||||
<string name="settings_agenda_range">Zakres agendy</string>
|
||||
<string name="settings_agenda_range_hint">Jak daleko w przód wyświetlane są wydarzenia na ekranie Agendy.</string>
|
||||
<string name="settings_agenda_widget_range">Zakres widżetu agendy</string>
|
||||
<string name="settings_agenda_widget_range_hint">Jak daleko w przód wyświetlane są wydarzenia w widżecie agendy na ekranie domowym.</string>
|
||||
<string name="settings_agenda_range_bar">Pasek zakresu</string>
|
||||
<string name="settings_agenda_range_bar_hint">Wyświetl u góry agendy pasek z zakresem widocznych dat oraz przyciskiem umożliwiającym zmianę zakresu na czas trwania sesji</string>
|
||||
<string name="agenda_range_day">Dzisiaj</string>
|
||||
<string name="agenda_range_this_week">Bieżący tydzień</string>
|
||||
<string name="agenda_range_this_month">Bieżący miesiąc</string>
|
||||
<string name="agenda_range_week">Następne 7 dni</string>
|
||||
<string name="agenda_range_month">Następne 30 dni</string>
|
||||
<string name="agenda_range_custom">Niestandardowy…</string>
|
||||
<string name="agenda_range_custom_hint">Dni</string>
|
||||
<string name="agenda_range_override_hint">Ustawienie tymczasowe — po ponownym otwarciu aplikacji Calendula przywrócony zostanie zapisany zakres.</string>
|
||||
<string name="agenda_range_showing_label">Wyświetlanie wszystkich nadchodzących wydarzeń dla</string>
|
||||
<plurals name="agenda_range_days">
|
||||
<item quantity="one">%d dzień</item>
|
||||
<item quantity="few">%d dni</item>
|
||||
<item quantity="many">%d dni</item>
|
||||
<item quantity="other">%d dni</item>
|
||||
</plurals>
|
||||
<string name="settings_section_views">Widoki</string>
|
||||
<string name="settings_quick_switch_header">Przycisk szybkiego przełączania</string>
|
||||
<string name="settings_quick_switch_hint">Wybierz widoki, między którymi można przełączać się przyciskiem w prawym górnym rogu, i przeciągnij je, aby zmienić ich kolejność. Wyłączone widoki pozostaną dostępne w menu nawigacyjnym.</string>
|
||||
<string name="settings_drawer_order_header">Menu nawigacyjne</string>
|
||||
<string name="settings_drawer_order_hint">Przeciągnij aby zmienić kolejność widoków w menu nawigacyjnym.</string>
|
||||
<string name="reorder_drag_handle">Przeciągnij by zmienić kolejność</string>
|
||||
<string name="settings_section_event_form">Formularz nowego wydarzenia</string>
|
||||
<string name="settings_form_fields_hint">Pola wyświetlane domyślnie — cała reszta znajduje się pod przyciskiem „Więcej pól”</string>
|
||||
<string name="settings_autofocus_title">Ustaw kursor na tytule nowego wydarzenia</string>
|
||||
<string name="settings_autofocus_title_hint">Kiedy tworzysz nowe wydarzenie, umieść kursor w polu tytułu i od razu otwórz klawiaturę.</string>
|
||||
<string name="settings_color_unsupported">Zezwalaj na kolory w nieobsługiwanych kalendarzach</string>
|
||||
<string name="settings_color_unsupported_hint">Niektóre kalendarze (np. niektóre CalDAV) nie udostępniają zestawu kolorów; niestandardowy kolor wydarzenia może zostać utracony lub nadpisany przy następnej synchronizacji. Jest to ograniczenie tych kalendarzy, a nie coś, co Calendula może naprawić.</string>
|
||||
<string name="settings_section_notifications">Powiadomienia</string>
|
||||
<string name="settings_reminders">Przypomnienia o wydarzeniach</string>
|
||||
<string name="settings_reminders_hint">Widzisz podwójne przypomnienia? Inna aplikacja kalendarza również je wyświetla — wyłącz je w jednej z nich.</string>
|
||||
<string name="settings_default_reminder">Domyślne przypomnienie</string>
|
||||
<string name="settings_default_reminder_allday">Wydarzenia całodniowe</string>
|
||||
<string name="settings_allday_reminder_time">Godzina przypomnienia o wydarzeniach całodniowych</string>
|
||||
<string name="settings_allday_reminder_time_hint">Przypomnienia o wydarzeniach całodniowych zostaną pojawiają się o %1$s</string>
|
||||
<string name="reminder_none">Brak</string>
|
||||
<string name="reminder_use_default">Użyj domyślnego przypomnienia</string>
|
||||
<string name="reminder_custom_amount">jednostek</string>
|
||||
<string name="reminder_custom_set">Ustaw</string>
|
||||
<string name="settings_calendar_reminders_title">Przypomnienia dla poszczególnych kalendarzy</string>
|
||||
<string name="settings_calendar_reminders_hint">Zastąp ustawienia domyślne dla wybranych kalendarzy — oddzielnie dla wydarzeń terminowych i całodniowych. Kalendarz może zachować ustawienie domyślne, odrzucić je lub ustawić własne.</string>
|
||||
<string name="settings_calendar_reminder_inherits">Domyślne (%1$s)</string>
|
||||
<string name="settings_reliable_delivery">Niezawodne dostarczanie</string>
|
||||
<string name="settings_reliable_delivery_hint">Android może opóźniać przypomnienia, aby oszczędzać baterię. Dodaj Calendula do wyjątków, żeby przychodziły na czas.</string>
|
||||
<string name="settings_reliable_delivery_exempt">Wyłączono optymalizację baterii — przypomnienia przychodzą na czas.</string>
|
||||
<string name="settings_snooze_duration">Długość drzemki</string>
|
||||
<string name="settings_section_calendars">Kalendarze</string>
|
||||
<string name="settings_manage_calendars">Zarządzaj kalendarzami</string>
|
||||
<string name="settings_manage_calendars_hint">Twórz lokalne kalendarze; zarządzaj synchronizowanymi</string>
|
||||
<string name="settings_section_language">Język</string>
|
||||
<string name="settings_language">Język aplikacji</string>
|
||||
<string name="settings_language_auto">Domyślny systemu</string>
|
||||
<string name="settings_translate">Pomóż w tłumaczeniu</string>
|
||||
<string name="settings_translate_hint">Dodaj lub ulepsz tłumaczenie w Weblate</string>
|
||||
<string name="settings_appearance_subtitle">Motyw, domyślny widok, pierwszy dzień tygodnia</string>
|
||||
<string name="settings_views_subtitle">Kolejność przycisku szybkiego przełączania oraz menu</string>
|
||||
<string name="settings_event_form_subtitle">Domyślne pola dla nowych wydarzeń</string>
|
||||
<string name="settings_notifications_subtitle">Przypomnienia o wydarzeniach</string>
|
||||
<string name="settings_special_dates_subtitle">Urodziny i rocznice kontaktów</string>
|
||||
<string name="settings_section_special_dates">Szczególne daty kontaktów</string>
|
||||
<string name="settings_special_dates_enable">Pokaż daty kontaktów</string>
|
||||
<string name="settings_special_dates_enable_hint">Zsynchronizuj urodziny i inne daty z kontaktów z lokalnymi kalendarzami. Odczytuje kontakty wyłącznie z tego urządzenia — nic nie jest przesyłane, a Twoje kontakty nigdy nie ulegają zmianie.</string>
|
||||
<string name="settings_special_dates_type_birthday">Urodziny</string>
|
||||
<string name="settings_special_dates_type_anniversary">Rocznice</string>
|
||||
<string name="settings_special_dates_type_custom">Inne daty</string>
|
||||
<string name="settings_special_dates_template">Format tytułu</string>
|
||||
<string name="settings_special_dates_template_hint">Użyj {name} dla nazwy kontaktu i {year} dla roku (rok urodzenia lub rok rozpoczęcia rocznicy; ukryty, gdy jest nieznany).</string>
|
||||
<string name="settings_special_dates_reminders">Przypomnienia</string>
|
||||
<string name="settings_special_dates_show_year">Pokaż rok</string>
|
||||
<string name="settings_special_dates_show_year_hint">Uwzględniaj {year} w tytułach, jeśli jest znany</string>
|
||||
<string name="settings_special_dates_sync_now">Synchronizuj teraz</string>
|
||||
<string name="settings_special_dates_never_synced">Jeszcze nie zsynchronizowano</string>
|
||||
<string name="settings_special_dates_last_synced">Ostatnio zsynchronizowano %1$s</string>
|
||||
<string name="settings_special_dates_calendar_hint">Ustaw kolor i widoczność każdego kalendarza w sekcji Kalendarze.</string>
|
||||
<string name="settings_calendar_reminders_managed_hint">Ustaw w sekcji Szczególne daty kontaktów</string>
|
||||
<string name="settings_special_dates_paused_title">Wstrzymano</string>
|
||||
<string name="settings_special_dates_paused_hint">Aplikacja Calendula nie ma już dostępu do Twoich kontaktów, więc te kalendarze nie są aktualizowane.</string>
|
||||
<string name="settings_special_dates_grant">Przyznaj dostęp</string>
|
||||
<string name="settings_special_dates_disable_title">Wyłączyć daty kontaktów?</string>
|
||||
<string name="settings_special_dates_disable_all_message">To spowoduje usunięcie kalendarzy ze szczególnymi datami kontaktów oraz ich wydarzeń. Wszelkie dodane do nich przypomnienia lub notatki zostaną utracone.</string>
|
||||
<string name="settings_special_dates_disable_type_message">To spowoduje usunięcie kalendarza „%1$s” wraz z jego wydarzeniami. Wszelkie dodane do nich przypomnienia lub notatki zostaną utracone.</string>
|
||||
<string name="settings_special_dates_disable_confirm">Wyłącz</string>
|
||||
<string name="dialog_save">Zapisz</string>
|
||||
<string name="settings_section_about">O aplikacji</string>
|
||||
<string name="settings_license">Licencja</string>
|
||||
<string name="settings_license_value">MIT</string>
|
||||
<string name="settings_about_author">autorstwa Jean-Luc Makiola</string>
|
||||
<string name="settings_about_source">Kod źródłowy</string>
|
||||
<string name="settings_about_support">Wesprzyj rozwój</string>
|
||||
<string name="settings_about_version">Wersja %1$s</string>
|
||||
<string name="settings_about_logo_desc">Ikona aplikacji Calendula</string>
|
||||
<string name="settings_report_problem">Zgłoś problem</string>
|
||||
<string name="settings_report_problem_hint">Wyślij raport o awarii lub otwórz system zgłoszeń</string>
|
||||
<string name="calendars_title">Kalendarze</string>
|
||||
<string name="calendars_local_header">Twoje kalendarze</string>
|
||||
<string name="calendars_local_empty">Brak lokalnych kalendarzy. Utwórz kalendarz, aby zapisywać wydarzenia tylko na tym urządzeniu.</string>
|
||||
<string name="calendars_add">Dodaj kalendarz</string>
|
||||
<string name="calendars_disable_hint">Wyłącz kalendarz, aby ukryć go w aplikacji — wraz z jego wydarzeniami, filtrami i selektorami. Nic nie zostanie usunięte, a w każdej chwili możesz go tutaj ponownie włączyć.</string>
|
||||
<string name="calendars_show_in_app_a11y">Pokaż „%1$s” w aplikacji</string>
|
||||
<string name="calendars_synced_header">Synchronizowane kalendarze</string>
|
||||
<string name="calendars_synced_hint">Pochodzą z kont na Twoim urządzeniu. Możesz je tworzyć i edytować w powiązanych z nimi aplikacjach.</string>
|
||||
<string name="calendars_manage_in_app">Zarządzaj w aplikacji</string>
|
||||
<string name="calendars_account_menu_a11y">Więcej opcji dla %1$s</string>
|
||||
<string name="calendars_enable_all">Włącz wszystkie</string>
|
||||
<string name="calendars_disable_all">Wyłącz wszystkie</string>
|
||||
<string name="calendars_add_account">Dodaj konto</string>
|
||||
<string name="calendars_new_title">Nowy kalendarz</string>
|
||||
<string name="calendars_edit_title">Edytuj kalendarz</string>
|
||||
<string name="calendars_name_label">Nazwa</string>
|
||||
<string name="calendars_color_label">Kolor</string>
|
||||
<string name="calendars_description_hint">Dodaj opis</string>
|
||||
<string name="calendars_delete_confirm_title">Usunąć kalendarz?</string>
|
||||
<string name="calendars_delete_confirm_message">„%1$s” oraz wszystkie powiązane z nim wydarzenia zostaną trwale usunięte z tego urządzenia.</string>
|
||||
<string name="calendars_write_error">Nie udało się zapisać zmiany.</string>
|
||||
<string name="calendars_backup_header">Kopia zapasowa</string>
|
||||
<string name="calendars_backup_hint">Kalendarze lokalne nie są nigdzie synchronizowane, więc wyeksportuj je do pliku .ics, aby zachować kopię.</string>
|
||||
<string name="calendars_backup_action">Eksportuj jako plik .ics</string>
|
||||
<string name="calendars_export_title">Eksportuj kalendarze</string>
|
||||
<string name="calendars_export_hint">Wybierz kalendarze, które mają zostać uwzględnione w pliku .ics.</string>
|
||||
<string name="calendars_export_action">Eksportuj</string>
|
||||
<string name="calendars_restore_header">Przywróć</string>
|
||||
<string name="calendars_restore_action">Przywróć z pliku .ics</string>
|
||||
<string name="calendars_restore_hint">Importuj wydarzenia z kopii zapasowej lub innej aplikacji kalendarza.</string>
|
||||
<string name="calendars_auto_backup">Automatyczna kopia zapasowa</string>
|
||||
<string name="calendars_auto_backup_hint">Okresowo eksportuj swoje lokalne kalendarze do folderu jako plik .ics.</string>
|
||||
<string name="calendars_auto_backup_folder">Folder kopii zapasowej</string>
|
||||
<string name="calendars_auto_backup_folder_unset">Dotknij by wybrać folder</string>
|
||||
<string name="calendars_auto_backup_interval">Okres</string>
|
||||
<string name="calendars_auto_backup_every">Co %1$s</string>
|
||||
<string name="calendars_auto_backup_interval_min">Minimum 30 minut.</string>
|
||||
<string name="calendars_auto_backup_status_never">Nie ma jeszcze automatycznych kopii zapasowych</string>
|
||||
<string name="calendars_auto_backup_status_ok">Ostatnia kopia zapasowa: %1$s</string>
|
||||
<string name="calendars_auto_backup_status_failed">Ostatnia nieudana kopia zapasowa: %1$s</string>
|
||||
<string name="backup_channel_name">Kopia zapasowa</string>
|
||||
<string name="backup_channel_description">Ostrzega, jeśli automatyczne kopie zapasowe wielokrotnie się nie powiodą.</string>
|
||||
<string name="backup_failed_title">Automatyczna kopia zapasowa nie powiodła się</string>
|
||||
<string name="backup_failed_text">Calendula nie mogła zapisać pliku kopii zapasowej. Sprawdź folder kopii zapasowej w Ustawieniach.</string>
|
||||
<string name="calendars_backup_failed">Nie udało się wyeksportować kopii zapasowej.</string>
|
||||
<plurals name="calendars_backup_done">
|
||||
<item quantity="one">Wyeksportowano %d wydarzenie.</item>
|
||||
<item quantity="few">Wyeksportowano %d wydarzenia.</item>
|
||||
<item quantity="many">Wyeksportowano %d wydarzeń.</item>
|
||||
<item quantity="other">Wyeksportowano %d wydarzeń.</item>
|
||||
</plurals>
|
||||
<string name="import_title">Importuj wydarzenia</string>
|
||||
<string name="import_target_header">Dodaj do kalendarza</string>
|
||||
<string name="import_empty">Nie znaleziono wydarzeń w tym w pliku.</string>
|
||||
<string name="import_failed">Nie udało się odczytać tego pliku.</string>
|
||||
<string name="import_no_calendar">Brak kalendarza z możliwością zapisu, do którego można zaimportować dane. Najpierw utwórz kalendarz lokalny.</string>
|
||||
<string name="import_done_title">Import zakończony</string>
|
||||
<string name="import_done_dedup_note">Wydarzenia znajdujące się już w kalendarzu zostały pominięte.</string>
|
||||
<string name="import_done_added_label">Dodano</string>
|
||||
<string name="import_done_skipped_label">Duplikaty</string>
|
||||
<string name="import_close">Zamknij</string>
|
||||
<string name="import_warning_recurrence">Niektóre zmienione wystąpienia zdarzeń cyklicznych zostały pominięte.</string>
|
||||
<string name="import_warning_no_start">Wydarzenie bez określonej godziny rozpoczęcia zostało pominięte.</string>
|
||||
<string name="import_warning_attendees">Listy gości nie zostały zaimportowane.</string>
|
||||
<string name="import_warning_timezone">Nieznana strefa czasowa została zastąpiona strefą ustawioną na Twoim urządzeniu.</string>
|
||||
<string name="import_button">Importuj</string>
|
||||
<plurals name="import_title_count">
|
||||
<item quantity="one">Importowanie %d wydarzenia</item>
|
||||
<item quantity="few">Importowanie %d wydarzeń</item>
|
||||
<item quantity="many">Importowanie %d wydarzeń</item>
|
||||
<item quantity="other">Importowanie %d wydarzeń</item>
|
||||
</plurals>
|
||||
<plurals name="import_event_count">
|
||||
<item quantity="one">%d wydarzenie w tym pliku.</item>
|
||||
<item quantity="few">%d wydarzenia w tym pliku.</item>
|
||||
<item quantity="many">%d wydarzeń w tym pliku.</item>
|
||||
<item quantity="other">%d wydarzeń w tym pliku.</item>
|
||||
</plurals>
|
||||
<plurals name="import_action">
|
||||
<item quantity="one">Importuj %d wydarzenie</item>
|
||||
<item quantity="few">Importuj %d wydarzenia</item>
|
||||
<item quantity="many">Importuj %d wydarzeń</item>
|
||||
<item quantity="other">Importuj %d wydarzeń</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_imported">
|
||||
<item quantity="one">Zaimportowano %d wydarzenie.</item>
|
||||
<item quantity="few">Zaimportowano %d wydarzenia.</item>
|
||||
<item quantity="many">Zaimportowano %d wydarzeń.</item>
|
||||
<item quantity="other">Zaimportowano %d wydarzeń.</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_skipped">
|
||||
<item quantity="one">Pominięto %d wydarzenie znajdujące się już w tym kalendarzu.</item>
|
||||
<item quantity="few">Pominięto %d wydarzenia znajdujące się już w tym kalendarzu.</item>
|
||||
<item quantity="many">Pominięto %d wydarzeń znajdujących się już w tym kalendarzu.</item>
|
||||
<item quantity="other">Pominięto %d wydarzeń znajdujących się już w tym kalendarzu.</item>
|
||||
</plurals>
|
||||
<string name="shortcut_new_event_short">Nowe wydarzenie</string>
|
||||
<string name="shortcut_new_event_long">Utwórz nowe wydarzenie</string>
|
||||
<string name="qs_tile_new_event_label">Nowe wydarzenie</string>
|
||||
<string name="settings_qs_tile">Dodaj kafelek Szybkich ustawień</string>
|
||||
<string name="settings_qs_tile_hint">Dodaj kafelek „Nowe wydarzenie” do panelu Szybkich ustawień.</string>
|
||||
<string name="crash_dialog_title">Aplikacja %1$s uległa awarii</string>
|
||||
<string name="crash_dialog_message">Aplikacja %1$s została nieoczekiwanie zamknięta przy ostatnim uruchomieniu. Możesz pomóc nam to naprawić, wysyłając zgłoszenie o błędzie. Pozostanie ono na Twoim urządzeniu, dopóki nie zdecydujesz się go udostępnić, i nie zawiera żadnych danych osobowych ani treści z kalendarza — jedynie poniższe szczegóły techniczne.</string>
|
||||
<string name="crash_dialog_report">Zgłoś</string>
|
||||
<string name="crash_dialog_dismiss">Nie teraz</string>
|
||||
<string name="crash_report_issue_title">Raport o awarii</string>
|
||||
<string name="crash_report_clip_label">Raport o awarii %1$s</string>
|
||||
<string name="crash_report_copied">Raport skopiowany do schowka</string>
|
||||
<string name="crash_report_open_failed">Nie udało się otworzyć systemu zgłoszeń. Raport znajduje się w Twoim schowku.</string>
|
||||
<string name="crash_report_body_template">Dziękujemy za zgłoszenie awarii w aplikacji %1$s. Prosimy o dodanie wszelkich zapamiętanych szczegółów na temat wykonywanych czynności, a następnie przesłanie zgłoszenia.\n\n### Co się wydarzyło\n\n\n### Raport o awarii\n%2$s\n</string>
|
||||
<string name="crash_report_body_paste">_(Raport był zbyt długi dla tego linku — wklej go tutaj ze schowka)._</string>
|
||||
<string name="special_dates_calendar_custom">Szczególne daty</string>
|
||||
<string name="special_dates_default_title_birthday">Urodziny {name} ({year})</string>
|
||||
<string name="special_dates_default_title_anniversary">Rocznica {name} ({year})</string>
|
||||
<string name="special_dates_default_title_custom">{name}</string>
|
||||
<string name="event_detail_duplicate">Duplikuj</string>
|
||||
<string name="reminder_day_tomorrow">Jutro</string>
|
||||
<string name="reminder_day_yesterday">Wczoraj</string>
|
||||
<string name="agenda_no_more_today">Brak zaplanowanych wydarzeń</string>
|
||||
<string name="settings_soften_colors">Zredukuj intensywność kolorów kalendarza</string>
|
||||
<string name="settings_soften_colors_summary">Dopasuj intensywność kolorów kalendarza i wydarzeń do motywu. Wyłącz tę opcję, aby wyświetlać oryginalne kolory ze źródła kalendarza.</string>
|
||||
<string name="settings_agenda_show_today">Zawsze pokazuj dzisiejszy dzień</string>
|
||||
<string name="settings_agenda_show_today_hint">Zachowaj dzisiejszy dzień na górze agendy i widżetu, nawet gdy nie ma już na dziś żadnych zadań.</string>
|
||||
<string name="special_dates_calendar_birthday">Urodziny</string>
|
||||
<string name="special_dates_calendar_anniversary">Rocznice</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,60 @@
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
<string name="app_name">Calendula</string>
|
||||
<string name="app_tagline">Современный календарь.</string>
|
||||
<string name="state_loading">Загрузка…</string>
|
||||
<string name="state_retry">Попробовать ещё раз</string>
|
||||
<string name="state_failure_unknown">Что-то пошло не так.</string>
|
||||
<string name="state_failure_permission">Требуется доступ к календарю.</string>
|
||||
<string name="state_failure_permission_action">Предоставить доступ</string>
|
||||
<string name="state_failure_no_calendars">Нет настроенных календарей.</string>
|
||||
<string name="state_failure_no_calendars_action">Перейти в системные настройки календаря</string>
|
||||
<string name="state_failure_provider">Не удалось просмотреть календарь.</string>
|
||||
<string name="permission_rationale_title">Следите за всеми своими событиями. С красотой</string>
|
||||
<string name="permission_rationale_body">Calendula требуется доступ к вашему календарю, чтобы показывать и управлять вашими событиями. Это всё, что приложение требует с самого начала – и никакая информация не покидает ваш девайс.</string>
|
||||
<string name="permission_request_button">Дать доступ к календарю</string>
|
||||
<string name="permission_denied_title">В доступе к календарю отказано</string>
|
||||
<string name="permission_denied_body">Calendula не может показать события без доступа к календарю. Предоставьте его снова в настройках телефона.</string>
|
||||
<string name="permission_open_settings_button">Открыть настройки телефона</string>
|
||||
<string name="permission_retry_button">Попытайтесь снова</string>
|
||||
<string name="permission_benefit_private_title">Остаётся на вашем устройстве</string>
|
||||
<string name="permission_benefit_private_body">Ваши календари просматриваются локально и информация никогда не покидает устройство.</string>
|
||||
<string name="permission_benefit_sync_title">Все ваши календари. Вместе</string>
|
||||
<string name="permission_benefit_sync_body">Google, CalDAV, локальный календарь – всё синхронизированное с устройством просто подключается.</string>
|
||||
<string name="permission_benefit_privacy_title">Никакого отслеживания. Никогда</string>
|
||||
<string name="permission_benefit_privacy_body">Ноль телеметрии. ноль аналитики, без рекламы.</string>
|
||||
<string name="permission_privacy_footnote">Остаётся на устройстве · не требует доступа к интернету</string>
|
||||
<string name="month_prev">Предыдущий месяц</string>
|
||||
<string name="month_next">Следующий месяц</string>
|
||||
<string name="month_today_action">Сегодня</string>
|
||||
<string name="month_more_actions">Больше действий</string>
|
||||
<string name="month_open_menu">Открыть меню</string>
|
||||
<string name="month_action_settings">Настройки</string>
|
||||
<string name="month_a11y_today_prefix">Сегодня</string>
|
||||
<string name="week_today_action">Эта неделя</string>
|
||||
<string name="week_number_label">Нед</string>
|
||||
<string name="day_today_action">Сегодня</string>
|
||||
<string name="event_detail_back">Назад</string>
|
||||
<string name="event_detail_edit">Редактировать</string>
|
||||
<string name="event_detail_delete">Удалить</string>
|
||||
<string name="event_detail_share">Поделиться</string>
|
||||
<string name="event_detail_duplicate">Дублировать</string>
|
||||
<string name="event_share_chooser_title">Поделиться событием</string>
|
||||
<string name="event_share_failed">Не удалось поделиться этим событием.</string>
|
||||
<string name="event_delete_title">Удалить событие?</string>
|
||||
<string name="event_delete_body">Это событие удалено из вашего календаря и всех устройств с которыми он синхронизирован.</string>
|
||||
<string name="event_delete_recurring_title">Удалить повторяющееся событие</string>
|
||||
<string name="event_delete_option_occurrence">Только это событие</string>
|
||||
<string name="event_delete_option_following">Это и все следующие события</string>
|
||||
<string name="event_delete_option_series">Все события в последовательности</string>
|
||||
<string name="event_edit_recurring_title">Редактировать повторяющееся событие</string>
|
||||
<string name="event_delete_failed">Не удалось удалить событие</string>
|
||||
<string name="event_delete_write_denied">Calendula требуется доступ для удаления событий</string>
|
||||
<string name="dialog_cancel">Отмена</string>
|
||||
<string name="dialog_ok">ОК</string>
|
||||
<string name="event_edit_new_title">Новое событие</string>
|
||||
<string name="event_edit_close">Закрыть</string>
|
||||
<string name="event_edit_save">Сохранить</string>
|
||||
<string name="event_edit_title_hint">Добавить имя</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<resources>
|
||||
<string name="app_name">Calendula</string>
|
||||
<string name="app_tagline">A modern calendar.</string>
|
||||
<!-- Alternative launcher label offered by the "App name" setting (issue #44).
|
||||
translatable="false": this string becomes a launcher component's label, so
|
||||
it must never be changed by an unreviewed community translation. -->
|
||||
<string name="app_name_calendar_alias" translatable="false">Calendar</string>
|
||||
|
||||
<!-- Loading / Failure / Success generic strings (used across screens) -->
|
||||
<string name="state_loading">Loading…</string>
|
||||
@@ -99,6 +103,18 @@
|
||||
<string name="event_edit_availability">Availability</string>
|
||||
<string name="event_edit_visibility">Visibility</string>
|
||||
|
||||
<!-- Event form — time zone -->
|
||||
<string name="event_edit_timezone_device">Device time zone</string>
|
||||
<string name="event_edit_timezone_device_summary">Follows wherever you are</string>
|
||||
<string name="event_edit_timezone_search">Search time zones</string>
|
||||
<string name="event_edit_timezone_recent">Recent</string>
|
||||
<string name="event_edit_timezone_all">All time zones</string>
|
||||
<string name="event_edit_timezone_none">No time zone matches “%1$s”</string>
|
||||
<!-- Shown under the time fields when the event is pinned to another zone:
|
||||
the same event expressed where the user actually is. %1$s is a time
|
||||
range, e.g. "2:00 PM – 3:00 PM". -->
|
||||
<string name="event_edit_timezone_local_time">%1$s your time</string>
|
||||
|
||||
<!-- Event form — per-event color -->
|
||||
<string name="event_edit_color">Color</string>
|
||||
<string name="event_edit_color_default">Calendar color</string>
|
||||
@@ -136,6 +152,9 @@
|
||||
<string name="recurrence_unit_weeks">weeks</string>
|
||||
<string name="recurrence_unit_months">months</string>
|
||||
<string name="recurrence_unit_years">years</string>
|
||||
<!-- Shown in place of the live rule read-out when an amount field holds a
|
||||
value outside 1–999 (i.e. 0), so the greyed-out OK button has a reason. -->
|
||||
<string name="event_edit_recurrence_incomplete">Enter a number from 1 to 999</string>
|
||||
<string name="event_edit_recurrence_ends">Ends</string>
|
||||
<string name="event_edit_recurrence_end_never">Never</string>
|
||||
<string name="event_edit_recurrence_end_until">On a date</string>
|
||||
@@ -257,6 +276,12 @@
|
||||
<string name="agenda_header_tomorrow">Tomorrow</string>
|
||||
<string name="agenda_empty_title">You\'re all caught up</string>
|
||||
<string name="agenda_no_more_today">No more events today</string>
|
||||
<!-- Time line for a multi-day event, on its first / last day (%1$s is a time, e.g. "14:00"). -->
|
||||
<string name="agenda_span_starts">Starts %1$s</string>
|
||||
<string name="agenda_span_ends">Ends %1$s</string>
|
||||
|
||||
<!-- Jump to today (toolbar icon button; see settings_today_toolbar) -->
|
||||
<string name="today_jump_action">Go to today</string>
|
||||
|
||||
<!-- Event search -->
|
||||
<string name="search_action">Search</string>
|
||||
@@ -308,6 +333,10 @@
|
||||
<string name="settings_week_start_auto">Automatic</string>
|
||||
<string name="settings_week_numbers">Week numbers</string>
|
||||
<string name="settings_week_numbers_summary">Show calendar-week numbers in month view</string>
|
||||
<string name="settings_today_toolbar">Today button in toolbar</string>
|
||||
<string name="settings_today_toolbar_summary">Show a jump-to-today button in the toolbar instead of a floating button</string>
|
||||
<string name="settings_app_name">App name</string>
|
||||
<string name="settings_app_name_summary">Show Calendula as “Calendar” in your launcher. Only the launcher name changes; the icon may move to a new spot after switching.</string>
|
||||
<string name="settings_time_format">Time format</string>
|
||||
<string name="settings_time_format_auto">Automatic</string>
|
||||
<string name="settings_time_format_12h">12-hour (2:00 PM)</string>
|
||||
@@ -343,6 +372,20 @@
|
||||
<item quantity="other">%d days</item>
|
||||
</plurals>
|
||||
<string name="settings_section_views">Views</string>
|
||||
<!-- Month view style (#38, #53) -->
|
||||
<string name="settings_month_header">Month view</string>
|
||||
<string name="settings_month_view_style">Month view style</string>
|
||||
<string name="month_style_paged">Pages</string>
|
||||
<string name="month_style_paged_summary">One month fills the screen. Swipe left or right to change month.</string>
|
||||
<string name="month_style_continuous">Scrolling months</string>
|
||||
<string name="month_style_continuous_summary">Each month sits under its own heading, with a little space setting it apart from the next.</string>
|
||||
<string name="month_style_dense">Seamless weeks</string>
|
||||
<string name="month_style_dense_summary">The weeks run on without a break, each month flowing straight into the next with no gap between them.</string>
|
||||
<string name="month_style_split">Split</string>
|
||||
<string name="month_style_split_summary">A compact grid marks the days that have events, and the day you tap is listed underneath.</string>
|
||||
<string name="month_split_no_events">Nothing scheduled</string>
|
||||
<string name="month_split_expand">Show the whole month</string>
|
||||
<string name="month_split_collapse">Show the day\'s events</string>
|
||||
<string name="settings_quick_switch_header">Quick-switch button</string>
|
||||
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu.</string>
|
||||
<string name="settings_drawer_order_header">Navigation menu</string>
|
||||
@@ -382,7 +425,7 @@
|
||||
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
|
||||
<!-- Hub category subtitles -->
|
||||
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
|
||||
<string name="settings_views_subtitle">Quick-switch button and menu order</string>
|
||||
<string name="settings_views_subtitle">Month layout, quick-switch button, menu order</string>
|
||||
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
||||
<string name="settings_notifications_subtitle">Event reminders</string>
|
||||
<string name="settings_special_dates_subtitle">Contact birthdays & anniversaries</string>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
-->
|
||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="en" />
|
||||
<locale android:name="ar" />
|
||||
<locale android:name="de" />
|
||||
<locale android:name="es" />
|
||||
<locale android:name="fr" />
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package de.jeanlucmakiola.calendula.data.appname
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Pure decision logic behind the app-name toggle (issue #44). The framework seam
|
||||
* ([LauncherNameManager.set]/[current], which call `PackageManager`) is covered
|
||||
* on-device; these tests pin the read interpretation and the write ordering.
|
||||
*/
|
||||
class LauncherNameManagerTest {
|
||||
|
||||
@Test
|
||||
fun `enabled calendar alias reads as CALENDAR`() {
|
||||
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_ENABLED))
|
||||
.isEqualTo(LauncherName.CALENDAR)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default and disabled calendar alias read as CALENDULA`() {
|
||||
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT))
|
||||
.isEqualTo(LauncherName.CALENDULA)
|
||||
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DISABLED))
|
||||
.isEqualTo(LauncherName.CALENDULA)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `write plan enables the target alias before disabling the other`() {
|
||||
val toCalendar = aliasWritePlan(LauncherName.CALENDAR)
|
||||
assertThat(toCalendar).containsExactly(
|
||||
AliasStateChange(LauncherAlias.CALENDAR, enabled = true),
|
||||
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
|
||||
).inOrder()
|
||||
|
||||
val toCalendula = aliasWritePlan(LauncherName.CALENDULA)
|
||||
assertThat(toCalendula).containsExactly(
|
||||
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
|
||||
AliasStateChange(LauncherAlias.CALENDAR, enabled = false),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `write plan never disables both aliases in the same step`() {
|
||||
// The first step is always an enable, so the launcher can never observe a
|
||||
// zero-entry transient regardless of switch direction.
|
||||
for (target in LauncherName.entries) {
|
||||
assertThat(aliasWritePlan(target).first().enabled).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,14 @@ class EventWriteMapperTest {
|
||||
isAllDay: Boolean = false,
|
||||
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
|
||||
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)),
|
||||
): EventForm = EventForm(calendarId = 1L, isAllDay = isAllDay, start = start, end = end)
|
||||
timezone: String? = null,
|
||||
): EventForm = EventForm(
|
||||
calendarId = 1L,
|
||||
isAllDay = isAllDay,
|
||||
start = start,
|
||||
end = end,
|
||||
timezone = timezone,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `timed event resolves wall clock in the given zone`() {
|
||||
@@ -30,6 +37,26 @@ class EventWriteMapperTest {
|
||||
assertThat(times.timezone).isEqualTo("Europe/Berlin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pinned zone wins over the device zone`() {
|
||||
val times = form(timezone = "America/New_York").toWriteTimes(berlin)
|
||||
// 10:00 in New York (EDT, UTC-4) == 14:00Z, not 08:00Z as Berlin would give.
|
||||
assertThat(times.timezone).isEqualTo("America/New_York")
|
||||
assertThat(times.dtStartMillis).isEqualTo(1_781_186_400_000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unparseable pinned zone falls back to the device zone`() {
|
||||
val times = form(timezone = "Mars/Olympus_Mons").toWriteTimes(berlin)
|
||||
assertThat(times.timezone).isEqualTo("Europe/Berlin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day ignores a pinned zone and stays UTC`() {
|
||||
val times = form(isAllDay = true, timezone = "America/New_York").toWriteTimes(berlin)
|
||||
assertThat(times.timezone).isEqualTo("UTC")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day event lives at UTC midnights with exclusive end`() {
|
||||
val times = form(isAllDay = true).toWriteTimes(berlin)
|
||||
@@ -96,8 +123,18 @@ class EventWriteMapperTest {
|
||||
|
||||
private val seriesStart = 1_700_000_000_000L
|
||||
|
||||
private fun update(original: EventForm, updated: EventForm): Map<String, Any?> =
|
||||
buildEventUpdateValues(original, updated, seriesStart, berlin)
|
||||
private fun update(
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
series: Long = seriesStart,
|
||||
): Map<String, Any?> = buildEventUpdateValues(original, updated, series, berlin)
|
||||
|
||||
/** The instant [local] names in [zoneId], as the provider would store it. */
|
||||
private fun instantAt(local: String, zoneId: String): Long =
|
||||
java.time.LocalDateTime.parse(local)
|
||||
.atZone(java.time.ZoneId.of(zoneId))
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
|
||||
@Test
|
||||
fun `pristine form produces no values`() {
|
||||
@@ -105,6 +142,42 @@ class EventWriteMapperTest {
|
||||
assertThat(update(original, original.copy())).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `editing the time of a pinned event keeps its zone`() {
|
||||
// The regression this guards: the update used to stamp the device zone
|
||||
// over the event's own, silently un-anchoring a foreign-zone event so it
|
||||
// stopped tracking that zone across DST.
|
||||
val original = form(timezone = "America/New_York")
|
||||
val values = update(original, original.copy(title = "Standup", start = original.start))
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_TIMEZONE)
|
||||
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)),
|
||||
)
|
||||
assertThat(update(original, moved)[CalendarContract.Events.EVENT_TIMEZONE])
|
||||
.isEqualTo("America/New_York")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changing only the zone still moves the event`() {
|
||||
// Same wall-clock, different zone: the instant moves, so DTSTART must be
|
||||
// rewritten even though start/end compare equal.
|
||||
val original = form()
|
||||
val values = update(original, original.copy(timezone = "America/New_York"))
|
||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York")
|
||||
// 10:00 Berlin (08:00Z) -> 10:00 New York (14:00Z): six hours later.
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_186_400_000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unpinning back to the device zone rewrites the times`() {
|
||||
val original = form(timezone = "America/New_York")
|
||||
val values = update(original, original.copy(timezone = null))
|
||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text-only edit writes just the changed columns`() {
|
||||
val original = form()
|
||||
@@ -156,6 +229,77 @@ class EventWriteMapperTest {
|
||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pinning a recurring event to another zone keeps the series wall clock`() {
|
||||
// The regression this guards: the series DTSTART used to move by a
|
||||
// millisecond delta measured at the *edited occurrence*. Here the series
|
||||
// anchor sits in January (Berlin CET, +1) and the edited occurrence in
|
||||
// July (Berlin CEST, +2), so the July delta is an hour off for January —
|
||||
// the whole series would have drifted to 10:00 Tokyo.
|
||||
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||
val original = form(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||
).copy(rrule = "FREQ=WEEKLY")
|
||||
|
||||
val values = update(original, original.copy(timezone = "Asia/Tokyo"), series)
|
||||
|
||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Asia/Tokyo")
|
||||
// The anchor still reads 09:00 — now 09:00 in Tokyo, not 10:00.
|
||||
assertThat(values[CalendarContract.Events.DTSTART])
|
||||
.isEqualTo(instantAt("2026-01-07T09:00", "Asia/Tokyo"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a time edit moves the series anchor in wall clock across a DST boundary`() {
|
||||
// Anchor in winter, edited occurrence in summer: pushing the occurrence
|
||||
// one hour later must leave the anchor at 10:00 winter time, not at an
|
||||
// instant that re-reads as 11:00 once the offset differs.
|
||||
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||
val original = form(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||
).copy(rrule = "FREQ=WEEKLY")
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(11, 0)),
|
||||
)
|
||||
|
||||
assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART])
|
||||
.isEqualTo(instantAt("2026-01-07T10:00", "Europe/Berlin"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moving a recurring occurrence to another day shifts the anchor by whole days`() {
|
||||
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||
val original = form(
|
||||
start = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 0)),
|
||||
).copy(rrule = "FREQ=WEEKLY")
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(14, 30)),
|
||||
end = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(15, 30)),
|
||||
)
|
||||
|
||||
assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART])
|
||||
.isEqualTo(instantAt("2026-01-09T14:30", "Europe/Berlin"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching a recurring event to all-day anchors the series on a UTC midnight`() {
|
||||
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||
val original = form(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||
).copy(rrule = "FREQ=WEEKLY")
|
||||
|
||||
val values = update(original, original.copy(isAllDay = true), series)
|
||||
|
||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
|
||||
val dtStart = values[CalendarContract.Events.DTSTART] as Long
|
||||
assertThat(dtStart % (24L * 60 * 60 * 1000)).isEqualTo(0L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adding a recurrence keeps the times and writes rule plus duration`() {
|
||||
val original = form()
|
||||
|
||||
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -125,6 +126,30 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `month view style defaults to paged and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Paged)
|
||||
|
||||
prefs.setMonthViewStyle(MonthViewStyle.Split)
|
||||
assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Split)
|
||||
|
||||
prefs.setMonthViewStyle(MonthViewStyle.Continuous)
|
||||
assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Continuous)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage stored month view style falls back to paged`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(store)
|
||||
store.updateData { p ->
|
||||
val m = p.toMutablePreferences()
|
||||
m[SettingsPrefs.MONTH_VIEW_STYLE_KEY] = "Carousel"
|
||||
m
|
||||
}
|
||||
assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Paged)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage stored enum falls back to default`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
|
||||
@@ -117,6 +117,7 @@ class EventFormTest {
|
||||
attendees: List<Attendee> = emptyList(),
|
||||
eventColor: Int? = null,
|
||||
eventColorKey: String? = null,
|
||||
eventTimezone: String? = null,
|
||||
): EventDetail = EventDetail(
|
||||
instance = EventInstance(
|
||||
instanceId = 1L,
|
||||
@@ -138,6 +139,7 @@ class EventFormTest {
|
||||
accessLevel = accessLevel,
|
||||
eventColor = eventColor,
|
||||
eventColorKey = eventColorKey,
|
||||
eventTimezone = eventTimezone,
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -157,6 +159,117 @@ class EventFormTest {
|
||||
assertThat(prefilled.description).isEqualTo("Body")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm leaves an event in the device zone unpinned`() {
|
||||
val prefilled = detail(eventTimezone = "Europe/Berlin").toEditForm(
|
||||
beginMillis = 1_781_164_800_000L,
|
||||
endMillis = 1_781_164_800_000L + 3_600_000L,
|
||||
zone = berlin,
|
||||
)
|
||||
// Same zone as the device: pinning it would only make the picker appear
|
||||
// on every ordinary event.
|
||||
assertThat(prefilled.timezone).isNull()
|
||||
assertThat(prefilled.populatedFields()).doesNotContain(EventFormField.Timezone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm pins a foreign zone and shows the times in it`() {
|
||||
val prefilled = detail(eventTimezone = "America/New_York").toEditForm(
|
||||
beginMillis = 1_781_164_800_000L, // 08:00Z
|
||||
endMillis = 1_781_164_800_000L + 3_600_000L,
|
||||
zone = berlin,
|
||||
)
|
||||
assertThat(prefilled.timezone).isEqualTo("America/New_York")
|
||||
// 08:00Z is 10:00 in Berlin but 04:00 in New York — the form shows the
|
||||
// event's own wall-clock, which is what a later save re-anchors to.
|
||||
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(4, 0)))
|
||||
assertThat(prefilled.populatedFields()).contains(EventFormField.Timezone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm never pins a zone on an all-day event`() {
|
||||
// All-day rows carry a nominal "UTC" that is an anchor, not a location.
|
||||
val prefilled = detail(isAllDay = true, eventTimezone = "UTC").toEditForm(
|
||||
beginMillis = LocalDate(2026, 6, 11).toEpochDays() * 86_400_000L,
|
||||
endMillis = LocalDate(2026, 6, 12).toEpochDays() * 86_400_000L,
|
||||
zone = berlin,
|
||||
)
|
||||
assertThat(prefilled.timezone).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm ignores an unparseable stored zone`() {
|
||||
val prefilled = detail(eventTimezone = "Mars/Olympus_Mons").toEditForm(
|
||||
beginMillis = 1_781_164_800_000L,
|
||||
endMillis = 1_781_164_800_000L + 3_600_000L,
|
||||
zone = berlin,
|
||||
)
|
||||
// A malformed sync row must not be honoured, nor fail the open.
|
||||
assertThat(prefilled.timezone).isNull()
|
||||
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timesIn converts a pinned event into the target zone`() {
|
||||
val form = EventForm(
|
||||
calendarId = 1L,
|
||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)),
|
||||
timezone = "America/New_York",
|
||||
)
|
||||
val (start, end) = form.timesIn(berlin)!!
|
||||
// 08:00 New York (EDT, UTC-4) is 14:00 Berlin (CEST, UTC+2).
|
||||
assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 0)))
|
||||
assertThat(end).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timesIn crosses the date line when the offset pushes past midnight`() {
|
||||
val form = EventForm(
|
||||
calendarId = 1L,
|
||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(20, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(21, 0)),
|
||||
timezone = "America/New_York",
|
||||
)
|
||||
val (start, _) = form.timesIn(berlin)!!
|
||||
// 20:00 New York is 02:00 the NEXT day in Berlin — the date has to move
|
||||
// with it, not just the clock.
|
||||
assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 18), LocalTime(2, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timesIn tracks each zone's own DST rather than a fixed offset`() {
|
||||
// In January both are on standard time: 08:00 EST is 14:00 CET — the
|
||||
// same six hours as July, but only because both shifted. Late March,
|
||||
// when the US has sprung forward and Europe hasn't, the gap is five.
|
||||
val march = EventForm(
|
||||
calendarId = 1L,
|
||||
start = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(8, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(9, 0)),
|
||||
timezone = "America/New_York",
|
||||
)
|
||||
assertThat(march.timesIn(berlin)!!.first)
|
||||
.isEqualTo(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(13, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timesIn returns null when there is nothing to disambiguate`() {
|
||||
val base = EventForm(
|
||||
calendarId = 1L,
|
||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)),
|
||||
)
|
||||
// Unpinned: the form's times already are the local times.
|
||||
assertThat(base.timesIn(berlin)).isNull()
|
||||
// Pinned to the target itself: same thing.
|
||||
assertThat(base.copy(timezone = "Europe/Berlin").timesIn(berlin)).isNull()
|
||||
// All-day: date-anchored, so there's no zone conversion to show.
|
||||
assertThat(base.copy(isAllDay = true, timezone = "America/New_York").timesIn(berlin))
|
||||
.isNull()
|
||||
// Unparseable: can't convert, mustn't throw.
|
||||
assertThat(base.copy(timezone = "Mars/Olympus_Mons").timesIn(berlin)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm turns the exclusive all-day end into the last covered day`() {
|
||||
// 11th..13th = UTC midnights of the 11th and the (exclusive) 14th.
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Instant
|
||||
import java.util.Locale
|
||||
|
||||
class TimeZoneCatalogTest {
|
||||
|
||||
// A fixed instant so DST-dependent offsets can't drift with the wall clock:
|
||||
// 2026-06-11 is northern summer, i.e. Berlin on CEST and New York on EDT.
|
||||
private val summer: Instant = Instant.parse("2026-06-11T12:00:00Z")
|
||||
private val zones = timeZoneOptions(Locale.ENGLISH, summer)
|
||||
|
||||
private fun filter(query: String) = filterTimeZones(zones, query)
|
||||
|
||||
@Test
|
||||
fun `catalogue holds the real zones and drops the legacy aliases`() {
|
||||
assertThat(zones.map { it.id }).containsAtLeast("Europe/Berlin", "America/New_York")
|
||||
// Bare aliases the tz database keeps for compatibility would double up
|
||||
// the real zones in the picker.
|
||||
assertThat(zones.map { it.id }).containsNoneOf("EST", "CST6CDT", "UTC")
|
||||
assertThat(zones.none { it.id.startsWith("SystemV/") }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `option exposes city and region split from the id`() {
|
||||
val ny = zones.first { it.id == "America/New_York" }
|
||||
assertThat(ny.city).isEqualTo("New York")
|
||||
assertThat(ny.region).isEqualTo("America")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `label is the id with underscores undone`() {
|
||||
assertThat(zones.first { it.id == "America/New_York" }.label)
|
||||
.isEqualTo("America/New York")
|
||||
assertThat(zones.first { it.id == "Europe/Berlin" }.label).isEqualTo("Europe/Berlin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolved abbreviation is compact, not the long name`() {
|
||||
// Whatever CLDR gives ("CET"/"CEST" or a "GMT+.." fallback), it must be
|
||||
// short and space-free — that's the whole point of showing it instead of
|
||||
// "Central European Time".
|
||||
val berlin = zones.first { it.id == "Europe/Berlin" }
|
||||
assertThat(berlin.shortName).doesNotContain(" ")
|
||||
assertThat(berlin.shortName.length).isLessThan(berlin.displayName.length)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `descriptor pairs a named abbreviation with the offset`() {
|
||||
val option = TimeZoneOption(
|
||||
id = "Europe/Berlin",
|
||||
displayName = "Central European Time",
|
||||
shortName = "CET",
|
||||
offsetMinutes = 60,
|
||||
)
|
||||
assertThat(zoneDescriptor(option)).isEqualTo("CET · GMT+01:00")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `descriptor drops the offset when the abbreviation already is one`() {
|
||||
// Repeating the offset after an offset-shaped abbreviation would just say
|
||||
// the same thing twice.
|
||||
fun descriptorFor(shortName: String, offset: Int) = zoneDescriptor(
|
||||
TimeZoneOption(id = "X/Y", displayName = "", shortName = shortName, offsetMinutes = offset),
|
||||
)
|
||||
assertThat(descriptorFor("UTC", 0)).isEqualTo("UTC")
|
||||
assertThat(descriptorFor("GMT+05:30", 330)).isEqualTo("GMT+05:30")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offset is resolved at the given instant, not the current one`() {
|
||||
val berlin = zones.first { it.id == "Europe/Berlin" }
|
||||
// CEST in June, so +02:00 — a fixed +01:00 would mean we ignored DST.
|
||||
assertThat(berlin.offsetMinutes).isEqualTo(120)
|
||||
|
||||
val winter = timeZoneOptions(Locale.ENGLISH, Instant.parse("2026-01-11T12:00:00Z"))
|
||||
assertThat(winter.first { it.id == "Europe/Berlin" }.offsetMinutes).isEqualTo(60)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank query returns everything unchanged`() {
|
||||
assertThat(filter("")).isEqualTo(zones)
|
||||
assertThat(filter(" ")).isEqualTo(zones)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query matches the city, ignoring case and underscores`() {
|
||||
assertThat(filter("new york").map { it.id }).contains("America/New_York")
|
||||
assertThat(filter("NEW YORK").map { it.id }).contains("America/New_York")
|
||||
assertThat(filter("new_york").map { it.id }).contains("America/New_York")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query matches accented cities typed plainly`() {
|
||||
// Sao_Paulo has no accent in the id, but its localized name does — the
|
||||
// point is that a user typing plain ASCII still finds it.
|
||||
assertThat(filter("sao paulo").map { it.id }).contains("America/Sao_Paulo")
|
||||
assertThat(filter("zurich").map { it.id }).contains("Europe/Zurich")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query matches the full IANA id`() {
|
||||
assertThat(filter("europe/berlin").map { it.id }).contains("Europe/Berlin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query matches the abbreviation, case-insensitively`() {
|
||||
// "cest" isn't a city, id, or substring of "Central European Summer
|
||||
// Time", so the only way these match is via the abbreviation.
|
||||
val hits = filter("cest")
|
||||
assertThat(hits).isNotEmpty()
|
||||
assertThat(hits.map { it.id }).contains("Europe/Berlin")
|
||||
// Every hit genuinely carries that abbreviation — nothing bled in.
|
||||
assertThat(hits.all { it.shortName.equals("CEST", ignoreCase = true) }).isTrue()
|
||||
// Case doesn't matter.
|
||||
assertThat(filter("CEST").map { it.id }).isEqualTo(hits.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a city starting with the query outranks one merely containing it`() {
|
||||
val ids = filter("york").map { it.id }
|
||||
// "New York" contains "york"; nothing starts with it, so it should still
|
||||
// surface rather than being buried.
|
||||
assertThat(ids).contains("America/New_York")
|
||||
|
||||
// "col" starts Colombo but only appears mid-string elsewhere.
|
||||
val col = filter("col").map { it.id }
|
||||
assertThat(col.first()).isEqualTo("Asia/Colombo")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no match yields an empty list rather than everything`() {
|
||||
assertThat(filter("zzzznotazone")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single zone resolves the same way the catalogue does`() {
|
||||
val fromCatalogue = zones.first { it.id == "Europe/Berlin" }
|
||||
assertThat(timeZoneOptionOf("Europe/Berlin", Locale.ENGLISH, summer))
|
||||
.isEqualTo(fromCatalogue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown zone id resolves to null`() {
|
||||
assertThat(timeZoneOptionOf("Mars/Olympus_Mons")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gmt offsets format with a sign and padding`() {
|
||||
assertThat(formatGmtOffset(0)).isEqualTo("GMT")
|
||||
assertThat(formatGmtOffset(120)).isEqualTo("GMT+02:00")
|
||||
assertThat(formatGmtOffset(-300)).isEqualTo("GMT-05:00")
|
||||
// India is +05:30 — a whole-hour assumption would render this wrong.
|
||||
assertThat(formatGmtOffset(330)).isEqualTo("GMT+05:30")
|
||||
assertThat(formatGmtOffset(-210)).isEqualTo("GMT-03:30")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlin.time.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AgendaTimeLabelTest {
|
||||
|
||||
private val zone = TimeZone.UTC
|
||||
|
||||
private fun at(y: Int, mo: Int, d: Int, h: Int = 0, min: Int = 0): Instant =
|
||||
LocalDateTime(y, mo, d, h, min).toInstant(zone)
|
||||
|
||||
private fun event(start: Instant, end: Instant, isAllDay: Boolean = false) = EventInstance(
|
||||
instanceId = 1,
|
||||
eventId = 1,
|
||||
calendarId = 1,
|
||||
title = "e",
|
||||
start = start,
|
||||
end = end,
|
||||
isAllDay = isAllDay,
|
||||
color = 0,
|
||||
location = null,
|
||||
)
|
||||
|
||||
private fun labelOn(y: Int, mo: Int, d: Int, event: EventInstance) =
|
||||
agendaTimeLabel(event, LocalDate(y, mo, d), zone)
|
||||
|
||||
@Test
|
||||
fun `a single-day timed event is a start-end range`() {
|
||||
val e = event(at(2026, 7, 2, 12, 0), at(2026, 7, 2, 13, 0))
|
||||
|
||||
assertThat(labelOn(2026, 7, 2, e))
|
||||
.isEqualTo(AgendaTimeLabel.Range(e.start, e.end))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single-day all-day event is all-day`() {
|
||||
val e = event(at(2026, 7, 2), at(2026, 7, 3), isAllDay = true)
|
||||
|
||||
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-day timed event names the start, middle, and end days`() {
|
||||
val e = event(at(2026, 7, 1, 14, 0), at(2026, 7, 4, 10, 0))
|
||||
|
||||
assertThat(labelOn(2026, 7, 1, e)).isEqualTo(AgendaTimeLabel.Starts(e.start))
|
||||
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||
assertThat(labelOn(2026, 7, 3, e)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||
assertThat(labelOn(2026, 7, 4, e)).isEqualTo(AgendaTimeLabel.Ends(e.end))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-day all-day event is all-day on every day`() {
|
||||
val e = event(at(2026, 7, 2), at(2026, 7, 5), isAllDay = true)
|
||||
|
||||
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||
assertThat(labelOn(2026, 7, 3, e)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||
assertThat(labelOn(2026, 7, 4, e)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event begun before the shown day is not labelled as starting`() {
|
||||
// Runs 29 Jun 09:00 → 2 Jul 09:00; on 1 Jul it is mid-span, on 2 Jul it ends.
|
||||
val e = event(at(2026, 6, 29, 9, 0), at(2026, 7, 2, 9, 0))
|
||||
|
||||
assertThat(labelOn(2026, 7, 1, e)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.Ends(e.end))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlin.time.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class GroupAgendaDaysTest {
|
||||
|
||||
private val zone = TimeZone.UTC
|
||||
private val anchor = LocalDate(2026, 7, 1)
|
||||
// A wide window so clamping to the window end is out of the way unless tested.
|
||||
private val windowEnd = LocalDate(2026, 7, 31)
|
||||
|
||||
private fun at(y: Int, mo: Int, d: Int, h: Int = 0, min: Int = 0): Instant =
|
||||
LocalDateTime(y, mo, d, h, min).toInstant(zone)
|
||||
|
||||
private fun event(
|
||||
id: Long,
|
||||
title: String,
|
||||
start: Instant,
|
||||
end: Instant,
|
||||
isAllDay: Boolean = false,
|
||||
) = EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1,
|
||||
title = title,
|
||||
start = start,
|
||||
end = end,
|
||||
isAllDay = isAllDay,
|
||||
color = 0,
|
||||
location = null,
|
||||
)
|
||||
|
||||
private fun days(instances: List<EventInstance>) =
|
||||
groupAgendaDays(anchor, windowEnd, instances, zone)
|
||||
|
||||
@Test
|
||||
fun `a timed multi-day event lists on every day it spans`() {
|
||||
val e = event(1, "trip", at(2026, 7, 1, 14, 0), at(2026, 7, 3, 10, 0))
|
||||
|
||||
val result = days(listOf(e))
|
||||
|
||||
assertThat(result.map { it.date }).containsExactly(
|
||||
LocalDate(2026, 7, 1),
|
||||
LocalDate(2026, 7, 2),
|
||||
LocalDate(2026, 7, 3),
|
||||
).inOrder()
|
||||
result.forEach { assertThat(it.events).containsExactly(e) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day multi-day event stops before its exclusive next-midnight end`() {
|
||||
// 1–3 July inclusive: end is the exclusive midnight opening 4 July.
|
||||
val e = event(1, "holiday", at(2026, 7, 1), at(2026, 7, 4), isAllDay = true)
|
||||
|
||||
val result = days(listOf(e))
|
||||
|
||||
assertThat(result.map { it.date }).containsExactly(
|
||||
LocalDate(2026, 7, 1),
|
||||
LocalDate(2026, 7, 2),
|
||||
LocalDate(2026, 7, 3),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single-day event lists exactly once`() {
|
||||
val e = event(1, "lunch", at(2026, 7, 2, 12, 0), at(2026, 7, 2, 13, 0))
|
||||
|
||||
assertThat(days(listOf(e)).map { it.date })
|
||||
.containsExactly(LocalDate(2026, 7, 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event ending exactly at midnight does not reach the next day`() {
|
||||
val e = event(1, "late", at(2026, 7, 1, 22, 0), at(2026, 7, 2, 0, 0))
|
||||
|
||||
assertThat(days(listOf(e)).map { it.date })
|
||||
.containsExactly(LocalDate(2026, 7, 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event begun before the anchor is clamped to the anchor day`() {
|
||||
val e = event(1, "ongoing", at(2026, 6, 29, 9, 0), at(2026, 7, 2, 9, 0))
|
||||
|
||||
assertThat(days(listOf(e)).map { it.date }).containsExactly(
|
||||
LocalDate(2026, 7, 1),
|
||||
LocalDate(2026, 7, 2),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event running past the window is clamped to the last visible day`() {
|
||||
val narrowEnd = LocalDate(2026, 7, 2)
|
||||
val e = event(1, "long", at(2026, 7, 1, 9, 0), at(2026, 7, 5, 9, 0))
|
||||
|
||||
val result = groupAgendaDays(anchor, narrowEnd, listOf(e), zone)
|
||||
|
||||
assertThat(result.map { it.date }).containsExactly(
|
||||
LocalDate(2026, 7, 1),
|
||||
LocalDate(2026, 7, 2),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
// All-day events are stored at UTC midnights; resolving them in a device
|
||||
// zone east of UTC would leak them onto the following day (see spanLastDay).
|
||||
private val berlin = TimeZone.of("Europe/Berlin")
|
||||
|
||||
private fun utcMidnight(y: Int, mo: Int, d: Int): Instant =
|
||||
LocalDateTime(y, mo, d, 0, 0).toInstant(TimeZone.UTC)
|
||||
|
||||
@Test
|
||||
fun `a single-day all-day event does not leak onto the next day in an eastern zone`() {
|
||||
val e = event(1, "birthday", utcMidnight(2026, 7, 2), utcMidnight(2026, 7, 3), isAllDay = true)
|
||||
|
||||
val result = groupAgendaDays(anchor, windowEnd, listOf(e), berlin)
|
||||
|
||||
assertThat(result.map { it.date }).containsExactly(LocalDate(2026, 7, 2))
|
||||
assertThat(e.spansMultipleDays(berlin)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day multi-day event spans its true days in an eastern zone`() {
|
||||
// 2–4 July inclusive: exclusive end is the UTC midnight opening 5 July.
|
||||
val e = event(1, "holiday", utcMidnight(2026, 7, 2), utcMidnight(2026, 7, 5), isAllDay = true)
|
||||
|
||||
val result = groupAgendaDays(anchor, windowEnd, listOf(e), berlin)
|
||||
|
||||
assertThat(result.map { it.date }).containsExactly(
|
||||
LocalDate(2026, 7, 2),
|
||||
LocalDate(2026, 7, 3),
|
||||
LocalDate(2026, 7, 4),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yesterday's all-day event does not surface under the anchor day in an eastern zone`() {
|
||||
// The agenda window starts at the anchor's local midnight, which in Berlin is
|
||||
// 22:00 UTC the day before — so an all-day event that ended yesterday (whose
|
||||
// exclusive UTC-midnight end sits at that instant) is still returned by the
|
||||
// provider. It must not be clamped onto the anchor's "today" section (#65).
|
||||
val jul19 = LocalDate(2026, 7, 19)
|
||||
val e = event(1, "birthday", utcMidnight(2026, 7, 18), utcMidnight(2026, 7, 19), isAllDay = true)
|
||||
|
||||
val result = groupAgendaDays(jul19, LocalDate(2026, 8, 18), listOf(e), berlin)
|
||||
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day event that falls entirely past the window end is dropped`() {
|
||||
// The same guard, at the far edge: an instance whose true days are all beyond
|
||||
// windowEnd occupies no visible day and must not be clamped onto the last one.
|
||||
val e = event(1, "birthday", utcMidnight(2026, 7, 3), utcMidnight(2026, 7, 4), isAllDay = true)
|
||||
|
||||
val result = groupAgendaDays(anchor, LocalDate(2026, 7, 2), listOf(e), berlin)
|
||||
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `within a day all-day events sort before timed ones`() {
|
||||
val allDay = event(1, "birthday", at(2026, 7, 1), at(2026, 7, 2), isAllDay = true)
|
||||
val timed = event(2, "call", at(2026, 7, 1, 9, 0), at(2026, 7, 1, 10, 0))
|
||||
|
||||
val dayOne = days(listOf(timed, allDay)).first { it.date == anchor }
|
||||
|
||||
assertThat(dayOne.events).containsExactly(allDay, timed).inOrder()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The read side of the UNTIL round-trip. `SimpleRecurrence.toRRule` deliberately
|
||||
* writes the end of the chosen *local* day expressed in UTC (see its docs — the
|
||||
* provider applies UNTIL coarsely), so displaying it means converting back into
|
||||
* the device zone first. Reading the leading digits raw showed the wrong day for
|
||||
* zones behind UTC.
|
||||
*/
|
||||
class RecurrenceTextTest {
|
||||
|
||||
private val berlin = ZoneId.of("Europe/Berlin")
|
||||
private val losAngeles = ZoneId.of("America/Los_Angeles")
|
||||
private val utc = ZoneId.of("UTC")
|
||||
|
||||
@Test
|
||||
fun `UTC form converts back to the picked day west of UTC`() {
|
||||
// toRRule("2026-12-31", America/Los_Angeles) → 23:59:59 local = 07:59:59Z
|
||||
// on 1 Jan. Reading the digits raw would show 1 Jan 2027.
|
||||
assertThat(untilLocalDate("20270101T075959Z", losAngeles))
|
||||
.isEqualTo(LocalDate.of(2026, 12, 31))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `UTC form converts back to the picked day east of UTC`() {
|
||||
// Berlin in December is UTC+1: 23:59:59 local = 22:59:59Z the same day.
|
||||
assertThat(untilLocalDate("20261231T225959Z", berlin))
|
||||
.isEqualTo(LocalDate.of(2026, 12, 31))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `UTC form is unchanged at UTC itself`() {
|
||||
assertThat(untilLocalDate("20261231T235959Z", utc))
|
||||
.isEqualTo(LocalDate.of(2026, 12, 31))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `summer offset is honoured, not a fixed one`() {
|
||||
// Berlin in July is UTC+2, so the same local end-of-day lands at 21:59:59Z.
|
||||
assertThat(untilLocalDate("20260801T215959Z", berlin))
|
||||
.isEqualTo(LocalDate.of(2026, 8, 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `date-only form is already local and passes through`() {
|
||||
assertThat(untilLocalDate("20261231", losAngeles))
|
||||
.isEqualTo(LocalDate.of(2026, 12, 31))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `floating date-time form uses its date as-is`() {
|
||||
// No trailing Z, so it isn't an instant — no conversion may be applied.
|
||||
assertThat(untilLocalDate("20261231T235959", losAngeles))
|
||||
.isEqualTo(LocalDate.of(2026, 12, 31))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage returns null rather than throwing`() {
|
||||
assertThat(untilLocalDate("", berlin)).isNull()
|
||||
assertThat(untilLocalDate("nonsense", berlin)).isNull()
|
||||
assertThat(untilLocalDate("2026", berlin)).isNull()
|
||||
assertThat(untilLocalDate("20261340", berlin)).isNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.Month
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* What makes the continuous style's month blocks self-contained: a boundary week
|
||||
* keeps its seven columns but carries only the block's own month.
|
||||
*/
|
||||
class ClipWeekToMonthTest {
|
||||
|
||||
private val zone = TimeZone.UTC
|
||||
private val jul26 = YearMonth(2026, Month.JULY)
|
||||
|
||||
private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long = 1L) = EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = "A",
|
||||
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
isAllDay = true,
|
||||
color = 0xFF445566.toInt(),
|
||||
location = null,
|
||||
)
|
||||
|
||||
private fun timed(date: LocalDate, hour: Int, id: Long = 2L) = EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = "T",
|
||||
start = date.atTime(hour, 0).toInstant(zone),
|
||||
end = date.atTime(hour + 1, 0).toInstant(zone),
|
||||
isAllDay = false,
|
||||
color = 0xFF112233.toInt(),
|
||||
location = null,
|
||||
)
|
||||
|
||||
/** July 2026 starts on a Wednesday, so its first Monday-anchored row is Jun 29 – Jul 5. */
|
||||
private fun firstRowOfJuly(events: List<EventInstance>) =
|
||||
clipWeekToMonth(
|
||||
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone).first(),
|
||||
jul26,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the row keeps all seven days so the columns don't shift`() {
|
||||
val week = firstRowOfJuly(emptyList())
|
||||
assertThat(week.days).hasSize(7)
|
||||
assertThat(week.days.first()).isEqualTo(LocalDate(2026, 6, 29))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the neighbour month's events are dropped`() {
|
||||
val june = timed(LocalDate(2026, 6, 30), 9)
|
||||
val july = timed(LocalDate(2026, 7, 1), 9, id = 3L)
|
||||
val week = firstRowOfJuly(listOf(june, july))
|
||||
|
||||
assertThat(week.timedByDay.keys).doesNotContain(LocalDate(2026, 6, 30))
|
||||
assertThat(week.timedByDay[LocalDate(2026, 7, 1)]).containsExactly(july)
|
||||
assertThat(week.countByDay[LocalDate(2026, 6, 30)]).isNull()
|
||||
assertThat(week.countByDay[LocalDate(2026, 7, 1)]).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bar reaching in from the previous month is cut back to the 1st`() {
|
||||
// Jun 29 – Jul 2: columns 0..3 unclipped, 2..3 once July owns the row.
|
||||
val week = firstRowOfJuly(listOf(allDay(LocalDate(2026, 6, 29), LocalDate(2026, 7, 2))))
|
||||
|
||||
val span = week.spans.single()
|
||||
assertThat(span.startCol).isEqualTo(2) // Wednesday the 1st
|
||||
assertThat(span.endCol).isEqualTo(3)
|
||||
// Flat cap on the cut side: the event continues out of this block.
|
||||
assertThat(span.continuesLeft).isTrue()
|
||||
assertThat(span.continuesRight).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bar living entirely in the neighbour month disappears`() {
|
||||
val week = firstRowOfJuly(listOf(allDay(LocalDate(2026, 6, 29), LocalDate(2026, 6, 30))))
|
||||
assertThat(week.spans).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bar reaching out into the next month is cut at the last day`() {
|
||||
// Jul 29 – Aug 2 sits in July's final row (Jul 27 – Aug 2).
|
||||
val weeks = layoutMonthWeeks(
|
||||
jul26,
|
||||
DayOfWeek.MONDAY,
|
||||
listOf(allDay(LocalDate(2026, 7, 29), LocalDate(2026, 8, 2))),
|
||||
zone,
|
||||
)
|
||||
val span = clipWeekToMonth(weeks.last(), jul26).spans.single()
|
||||
assertThat(span.startCol).isEqualTo(2) // Wednesday the 29th
|
||||
assertThat(span.endCol).isEqualTo(4) // Friday the 31st
|
||||
assertThat(span.continuesRight).isTrue()
|
||||
assertThat(span.continuesLeft).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a row wholly inside the month is untouched`() {
|
||||
val mid = layoutMonthWeeks(
|
||||
jul26,
|
||||
DayOfWeek.MONDAY,
|
||||
listOf(timed(LocalDate(2026, 7, 8), 9), allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9))),
|
||||
zone,
|
||||
)[1]
|
||||
assertThat(clipWeekToMonth(mid, jul26)).isEqualTo(mid)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.Month
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The continuous grid's coordinate space. Every block's identity — and the
|
||||
* LazyColumn items it maps to — hangs off this arithmetic, so it gets its own
|
||||
* tests rather than being exercised only through the UI.
|
||||
*/
|
||||
class ContinuousMonthIndexTest {
|
||||
|
||||
private val jun26 = YearMonth(2026, Month.JUNE)
|
||||
|
||||
@Test
|
||||
fun `consecutive months are consecutive indices`() {
|
||||
val jun = monthIndexOf(jun26)
|
||||
assertThat(monthIndexOf(YearMonth(2026, Month.JULY))).isEqualTo(jun + 1)
|
||||
assertThat(monthIndexOf(YearMonth(2026, Month.MAY))).isEqualTo(jun - 1)
|
||||
// And across the year boundary.
|
||||
assertThat(monthIndexOf(YearMonth(2027, Month.JANUARY)))
|
||||
.isEqualTo(monthIndexOf(YearMonth(2026, Month.DECEMBER)) + 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `index round-trips back to its month`() {
|
||||
listOf(
|
||||
YearMonth(1900, Month.JANUARY),
|
||||
jun26,
|
||||
YearMonth(2026, Month.DECEMBER),
|
||||
YearMonth(2100, Month.DECEMBER),
|
||||
).forEach { ym ->
|
||||
assertThat(yearMonthForIndex(monthIndexOf(ym))).isEqualTo(ym)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `indices are non-negative and span 1900 through 2100`() {
|
||||
assertThat(monthIndexOf(YearMonth(1900, Month.JANUARY))).isEqualTo(0)
|
||||
assertThat(monthIndexOf(jun26)).isGreaterThan(0)
|
||||
assertThat(monthIndexOf(YearMonth(2100, Month.DECEMBER)))
|
||||
.isEqualTo(continuousMonthCount() - 1)
|
||||
assertThat(continuousMonthCount()).isEqualTo(201 * 12)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a month maps to its header item and back`() {
|
||||
val index = monthIndexOf(jun26)
|
||||
val item = itemIndexForMonth(index)
|
||||
// Both of a month's items — its sticky header and the block under it —
|
||||
// resolve back to the same month, so a title read off the first visible
|
||||
// item is right wherever the viewport sits inside the block.
|
||||
assertThat(monthIndexForItem(item)).isEqualTo(index)
|
||||
assertThat(monthIndexForItem(item + 1)).isEqualTo(index)
|
||||
assertThat(monthIndexForItem(item + 2)).isEqualTo(index + 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `week rows follow the month's shape and its week start`() {
|
||||
// June 2026 starts on a Monday: 30 days → 5 rows either way.
|
||||
assertThat(weekRowsInMonth(jun26, DayOfWeek.MONDAY)).isEqualTo(5)
|
||||
assertThat(weekRowsInMonth(jun26, DayOfWeek.SUNDAY)).isEqualTo(5)
|
||||
// February 2026 starts on a Sunday: a Sunday-anchored grid fits it in 4
|
||||
// rows, a Monday-anchored one needs 5 — the block's height moves with the
|
||||
// preference even though its position in the list doesn't.
|
||||
val feb26 = YearMonth(2026, Month.FEBRUARY)
|
||||
assertThat(weekRowsInMonth(feb26, DayOfWeek.SUNDAY)).isEqualTo(4)
|
||||
assertThat(weekRowsInMonth(feb26, DayOfWeek.MONDAY)).isEqualTo(5)
|
||||
// February 2021 starts on a Monday and is 28 days → exactly 4.
|
||||
assertThat(weekRowsInMonth(YearMonth(2021, Month.FEBRUARY), DayOfWeek.MONDAY))
|
||||
.isEqualTo(4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the placeholder block is the size the loaded one will be`() {
|
||||
// Otherwise scrolling across an unloaded month jumps when its data lands.
|
||||
(0 until 24).forEach { offset ->
|
||||
val ym = yearMonthForIndex(monthIndexOf(jun26) + offset)
|
||||
DayOfWeek.entries.forEach { ws ->
|
||||
assertThat(weekRowsInMonth(ym, ws))
|
||||
.isEqualTo(layoutMonthWeeks(ym, ws, emptyList(), TimeZone.UTC).size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a window is clamped to the months that exist`() {
|
||||
// The regression this guards: an empty visible-items list reported as
|
||||
// "month 0", whose padded window ran to -4 — and yearMonthForIndex has
|
||||
// no month for that, so the whole grid failed with "couldn't read".
|
||||
assertThat(clampMonthWindow(-4..4)).isEqualTo(0..4)
|
||||
val last = continuousMonthCount() - 1
|
||||
assertThat(clampMonthWindow((last - 2)..(last + 4))).isEqualTo((last - 2)..last)
|
||||
assertThat(clampMonthWindow(10..20)).isEqualTo(10..20)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every index a clamped window can hold resolves to a month`() {
|
||||
listOf(clampMonthWindow(-4..4), clampMonthWindow((continuousMonthCount() - 1)..(continuousMonthCount() + 3)))
|
||||
.forEach { window ->
|
||||
window.forEach { index -> yearMonthForIndex(index) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the dense week window covers every week its months touch`() {
|
||||
// One month-window load has to lay out every Dense row those months
|
||||
// appear in, boundary weeks included, or rows would flicker as
|
||||
// placeholders while the data for them is already in hand.
|
||||
DayOfWeek.entries.forEach { ws ->
|
||||
val window = monthIndexOf(jun26)..monthIndexOf(YearMonth(2026, Month.AUGUST))
|
||||
val weeks = weekWindowFor(window, ws)
|
||||
window.forEach { index ->
|
||||
val ym = yearMonthForIndex(index)
|
||||
val first = weekIndexOf(firstOfMonth(ym), ws)
|
||||
val last = weekIndexOf(firstOfMonth(yearMonthForIndex(index + 1)).minus(1, DateTimeUnit.DAY), ws)
|
||||
assertThat(weeks.contains(first)).isTrue()
|
||||
assertThat(weeks.contains(last)).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a dense row reports the month that owns most of it`() {
|
||||
// June 29 – July 5, 2026 (Monday-anchored): four of its days are July's,
|
||||
// so the window follows July rather than snapping back to June.
|
||||
val boundary = weekIndexOf(LocalDate(2026, 6, 29), DayOfWeek.MONDAY)
|
||||
assertThat(monthIndexForWeek(boundary, DayOfWeek.MONDAY))
|
||||
.isEqualTo(monthIndexOf(YearMonth(2026, Month.JULY)))
|
||||
// A row wholly inside June reports June.
|
||||
assertThat(monthIndexForWeek(weekIndexOf(LocalDate(2026, 6, 15), DayOfWeek.MONDAY), DayOfWeek.MONDAY))
|
||||
.isEqualTo(monthIndexOf(jun26))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every day of a dense week shares one index`() {
|
||||
val mon = LocalDate(2026, 6, 8)
|
||||
val indices = (0..6).map { weekIndexOf(mon.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) }
|
||||
assertThat(indices.toSet()).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dense week indices round-trip and stay inside the list`() {
|
||||
val wed = LocalDate(2026, 6, 10)
|
||||
DayOfWeek.entries.forEach { ws ->
|
||||
val index = weekIndexOf(wed, ws)
|
||||
assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfGridWeek(ws))
|
||||
assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0)
|
||||
assertThat(index).isLessThan(continuousWeekCount(ws))
|
||||
assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws))
|
||||
.isLessThan(continuousWeekCount(ws))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `firstOfMonth is the 1st`() {
|
||||
assertThat(firstOfMonth(jun26)).isEqualTo(LocalDate(2026, 6, 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a window comfortably around the visible range is kept`() {
|
||||
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 40, lastVisible = 45)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nearing a loaded edge widens the window around the visible range`() {
|
||||
// Within two months of the top edge → reload, padded on both sides.
|
||||
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 1, lastVisible = 2))
|
||||
.isEqualTo(-4..7)
|
||||
|
||||
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 98, lastVisible = 100))
|
||||
.isEqualTo(93..105)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a jump far outside the window reloads around the destination`() {
|
||||
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 501))
|
||||
.isEqualTo(495..506)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the reloaded window always clears the trigger it just crossed`() {
|
||||
// Otherwise every scroll frame would re-trigger a query — and with the
|
||||
// window growing from a pad of 1, this has to hold at every rung of the
|
||||
// ladder, not just the widest one.
|
||||
(1..8).forEach { pad ->
|
||||
val loaded = (10 - pad)..(10 + pad)
|
||||
val past = 10 + pad + 1
|
||||
val widened = nextLoadWindow(loaded, past, past, pad)!!
|
||||
assertThat(nextLoadWindow(widened, past, past, pad)).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the reload trigger stays inside the pad`() {
|
||||
// A trigger at or beyond the pad would fire again the instant its own
|
||||
// reload landed, and the window would query forever.
|
||||
(1..8).forEach { pad -> assertThat(edgeForPad(pad)).isLessThan(pad) }
|
||||
// A one-month window has no room for hysteresis: reload only on contact.
|
||||
assertThat(edgeForPad(1)).isEqualTo(0)
|
||||
assertThat(edgeForPad(0)).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the smallest window reloads on crossing rather than nearing its edge`() {
|
||||
// The first frame loads one month either side of today, which leaves no
|
||||
// room to reload *before* the edge: at pad 1 the trigger is contact.
|
||||
// Only momentary — the first completed load widens the pad to 3, which
|
||||
// buys the usual head start back.
|
||||
val initial = 10..12
|
||||
assertThat(nextLoadWindow(initial, firstVisible = 12, lastVisible = 12, pad = 1)).isNull()
|
||||
assertThat(nextLoadWindow(initial, firstVisible = 13, lastVisible = 13, pad = 1))
|
||||
.isEqualTo(12..14)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the split selection follows the month, landing on today when it's there`() {
|
||||
val today = LocalDate(2026, 6, 10)
|
||||
assertThat(selectionForMonth(jun26, today)).isEqualTo(today)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the split selection falls to the 1st of any other month`() {
|
||||
val today = LocalDate(2026, 6, 10)
|
||||
assertThat(selectionForMonth(YearMonth(2026, Month.JULY), today))
|
||||
.isEqualTo(LocalDate(2026, 7, 1))
|
||||
assertThat(selectionForMonth(YearMonth(2025, Month.JUNE), today))
|
||||
.isEqualTo(LocalDate(2025, 6, 1))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.Month
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* [DayIndex] exists purely to make the scrolling styles cheap, so what it owes
|
||||
* is that cheapness changes *nothing*: a row laid out from the index must be the
|
||||
* row that would have come from scanning every instance.
|
||||
*
|
||||
* Run in a zone east of UTC, where the all-day anchoring and the day boundaries
|
||||
* disagree — the case that has bitten this codebase before (#65).
|
||||
*/
|
||||
class DayIndexTest {
|
||||
|
||||
private val zone = TimeZone.of("Europe/Berlin")
|
||||
private val mon = LocalDate(2026, 6, 8)
|
||||
private val week = (0..6).map { mon.plus(it, DateTimeUnit.DAY) }
|
||||
|
||||
private var nextId = 0L
|
||||
|
||||
private fun timed(from: LocalDate, startHour: Int, to: LocalDate, endHour: Int): EventInstance {
|
||||
val id = ++nextId
|
||||
return EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = "T$id",
|
||||
start = from.atTime(startHour, 0).toInstant(zone),
|
||||
end = to.atTime(endHour, 0).toInstant(zone),
|
||||
isAllDay = false,
|
||||
color = 0xFF112233.toInt(),
|
||||
location = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun allDay(from: LocalDate, toInclusive: LocalDate): EventInstance {
|
||||
val id = ++nextId
|
||||
return EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = "A$id",
|
||||
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
isAllDay = true,
|
||||
color = 0xFF445566.toInt(),
|
||||
location = null,
|
||||
)
|
||||
}
|
||||
|
||||
/** Every shape the layout distinguishes, plus the boundary cases around them. */
|
||||
private fun awkwardEvents(): List<EventInstance> = listOf(
|
||||
timed(mon, 9, mon, 10),
|
||||
// Crosses midnight into the next day.
|
||||
timed(mon.plus(1, DateTimeUnit.DAY), 23, mon.plus(2, DateTimeUnit.DAY), 1),
|
||||
// Ends exactly at midnight: covers its own day, not the next.
|
||||
timed(mon.plus(3, DateTimeUnit.DAY), 22, mon.plus(4, DateTimeUnit.DAY), 0),
|
||||
// Zero length, at midnight: covers nothing at all.
|
||||
timed(mon.plus(4, DateTimeUnit.DAY), 0, mon.plus(4, DateTimeUnit.DAY), 0),
|
||||
allDay(mon.plus(2, DateTimeUnit.DAY), mon.plus(2, DateTimeUnit.DAY)),
|
||||
// Runs in from before the row and out past its end.
|
||||
allDay(mon.minus(3, DateTimeUnit.DAY), mon.plus(9, DateTimeUnit.DAY)),
|
||||
// Wholly outside the row, both sides.
|
||||
timed(mon.minus(10, DateTimeUnit.DAY), 9, mon.minus(10, DateTimeUnit.DAY), 10),
|
||||
allDay(mon.plus(20, DateTimeUnit.DAY), mon.plus(21, DateTimeUnit.DAY)),
|
||||
// Two on one day, to pin the ordering the index hands back.
|
||||
timed(mon.plus(5, DateTimeUnit.DAY), 8, mon.plus(5, DateTimeUnit.DAY), 9),
|
||||
timed(mon.plus(5, DateTimeUnit.DAY), 14, mon.plus(5, DateTimeUnit.DAY), 15),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a row from the index is the row from a full scan`() {
|
||||
val events = awkwardEvents()
|
||||
val scanned = layoutCalendarWeek(week, events, zone)
|
||||
val indexed = layoutCalendarWeek(week, DayIndex(events, zone).eventsOn(week), zone)
|
||||
assertThat(indexed).isEqualTo(scanned)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a whole month agrees, row for row`() {
|
||||
val events = awkwardEvents()
|
||||
val jun = YearMonth(2026, Month.JUNE)
|
||||
DayOfWeek.entries.forEach { ws ->
|
||||
assertThat(layoutMonthWeeks(jun, ws, DayIndex(events, zone), zone))
|
||||
.isEqualTo(layoutMonthWeeks(jun, ws, events, zone))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event covering several of a row's days is listed once`() {
|
||||
// It is bucketed on each date it covers, so the row would otherwise see
|
||||
// it repeatedly and lay out a lane per copy.
|
||||
val long = allDay(mon, mon.plus(4, DateTimeUnit.DAY))
|
||||
val events = DayIndex(listOf(long), zone).eventsOn(week)
|
||||
assertThat(events).containsExactly(long)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `days no event touches come back empty`() {
|
||||
val index = DayIndex(listOf(timed(mon, 9, mon, 10)), zone)
|
||||
assertThat(index.eventsOn(listOf(mon.plus(1, DateTimeUnit.DAY)))).isEmpty()
|
||||
assertThat(index.eventsOn(emptyList())).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty calendar indexes to nothing`() {
|
||||
assertThat(DayIndex(emptyList(), zone).eventsOn(week)).isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.Month
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* What lets the split style's dots morph into the paged style's bars (#53): both
|
||||
* read a day off the *same* lane seating, so dot _i_ and lane _i_ are one event.
|
||||
*/
|
||||
class LaneEventsTest {
|
||||
|
||||
private val zone = TimeZone.UTC
|
||||
private val jul26 = YearMonth(2026, Month.JULY)
|
||||
|
||||
/** July 2026 starts on a Wednesday, so this row — Jul 6–12 — sits wholly inside it. */
|
||||
private fun rowOfJuly6(events: List<EventInstance>) =
|
||||
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)[1]
|
||||
|
||||
private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long, color: Int = BLUE) =
|
||||
EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = "A$id",
|
||||
start = from.atTime(0, 0).toInstant(zone),
|
||||
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone),
|
||||
isAllDay = true,
|
||||
color = color,
|
||||
location = null,
|
||||
)
|
||||
|
||||
private fun timed(date: LocalDate, hour: Int, id: Long, color: Int = RED) = EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = "T$id",
|
||||
start = date.atTime(hour, 0).toInstant(zone),
|
||||
end = date.atTime(hour + 1, 0).toInstant(zone),
|
||||
isAllDay = false,
|
||||
color = color,
|
||||
location = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a bar keeps its lane and the day's timed events fill what's left`() {
|
||||
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
|
||||
val meeting = timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)
|
||||
val week = rowOfJuly6(listOf(bar, meeting))
|
||||
|
||||
// Jul 7 is column 1 of a Monday-anchored row starting Jul 6.
|
||||
assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3))
|
||||
.containsExactly(bar, meeting)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a day the bar misses seats its own events from lane zero`() {
|
||||
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
|
||||
val monday = timed(LocalDate(2026, 7, 6), hour = 9, id = 2L)
|
||||
val week = rowOfJuly6(listOf(bar, monday))
|
||||
|
||||
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3))
|
||||
.containsExactly(monday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-day bar is seated on every day it covers`() {
|
||||
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
|
||||
val week = rowOfJuly6(listOf(bar))
|
||||
|
||||
(1..3).forEach { col ->
|
||||
val day = LocalDate(2026, 7, 6 + col)
|
||||
assertThat(week.laneEvents(col, day, laneCap = 3)).containsExactly(bar)
|
||||
}
|
||||
assertThat(week.laneEvents(col = 4, day = LocalDate(2026, 7, 10), laneCap = 3)).isEmpty()
|
||||
}
|
||||
|
||||
/** The bug the colour-gathered dots had: one dot for two events on one calendar. */
|
||||
@Test
|
||||
fun `events sharing a colour each keep their own lane`() {
|
||||
val first = timed(LocalDate(2026, 7, 6), hour = 9, id = 1L, color = RED)
|
||||
val second = timed(LocalDate(2026, 7, 6), hour = 14, id = 2L, color = RED)
|
||||
val week = rowOfJuly6(listOf(first, second))
|
||||
|
||||
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3))
|
||||
.containsExactly(first, second)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seating stops at the cap and leaves the rest to the overflow count`() {
|
||||
val events = (0 until 5).map { timed(LocalDate(2026, 7, 6), hour = 8 + it, id = it + 1L) }
|
||||
val week = rowOfJuly6(events)
|
||||
|
||||
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
|
||||
assertThat(seated).hasSize(3)
|
||||
assertThat(seated).containsExactlyElementsIn(events.take(3)).inOrder()
|
||||
assertThat(week.countByDay[LocalDate(2026, 7, 6)]!! - seated.size).isEqualTo(2)
|
||||
}
|
||||
|
||||
/** A bar parked below the cap is out of view, so it takes no dot with it. */
|
||||
@Test
|
||||
fun `a bar beyond the cap is left out`() {
|
||||
val bars = (0 until 4).map {
|
||||
allDay(LocalDate(2026, 7, 6), LocalDate(2026, 7, 8), id = it + 1L)
|
||||
}
|
||||
val week = rowOfJuly6(bars)
|
||||
|
||||
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
|
||||
assertThat(seated).hasSize(3)
|
||||
assertThat(week.spans.filter { it.lane >= 3 }.map { it.event })
|
||||
.containsNoneIn(seated)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val BLUE = 0xFF3366CC.toInt()
|
||||
const val RED = 0xFFCC3333.toInt()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlin.time.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class MonthLayoutTest {
|
||||
|
||||
private val zone = TimeZone.UTC
|
||||
|
||||
// 2026-06-01 is a Monday, so a Monday-anchored June grid starts on the 1st.
|
||||
private val jun = YearMonth(2026, kotlinx.datetime.Month.JUNE)
|
||||
private val jun1 = LocalDate(2026, 6, 1)
|
||||
private val jun8 = LocalDate(2026, 6, 8)
|
||||
private val weekOf8th = (0..6).map { jun8.plus(it, DateTimeUnit.DAY) }
|
||||
|
||||
private fun at(date: LocalDate, h: Int, m: Int = 0): Instant =
|
||||
date.atTime(h, m).toInstant(zone)
|
||||
|
||||
private fun timed(
|
||||
date: LocalDate,
|
||||
startHour: Int,
|
||||
endHour: Int,
|
||||
id: Long = 1L,
|
||||
title: String = "E",
|
||||
) = EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = title,
|
||||
start = at(date, startHour),
|
||||
end = at(date, endHour),
|
||||
isAllDay = false,
|
||||
color = 0xFF112233.toInt(),
|
||||
location = null,
|
||||
)
|
||||
|
||||
/** All-day events live at UTC midnights with an *exclusive* end. */
|
||||
private fun allDay(
|
||||
from: LocalDate,
|
||||
toInclusive: LocalDate = from,
|
||||
id: Long = 100L,
|
||||
title: String = "A",
|
||||
) = EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = title,
|
||||
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
isAllDay = true,
|
||||
color = 0xFF445566.toInt(),
|
||||
location = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `startOfGridWeek snaps back to the configured week start`() {
|
||||
val wed = LocalDate(2026, 6, 10)
|
||||
assertThat(wed.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8)
|
||||
assertThat(jun8.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8)
|
||||
// A Sunday-anchored week containing the 10th starts on the 7th.
|
||||
assertThat(wed.startOfGridWeek(DayOfWeek.SUNDAY)).isEqualTo(LocalDate(2026, 6, 7))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `monthGridRange always covers 42 days from the grid start`() {
|
||||
val range = monthGridRange(jun, DayOfWeek.MONDAY, zone)
|
||||
assertThat(range.start).isEqualTo(at(jun1, 0))
|
||||
// 42 days inclusive → the last second of 2026-07-12.
|
||||
assertThat(range.endInclusive)
|
||||
.isEqualTo(LocalDate(2026, 7, 12).atTime(23, 59, 59).toInstant(zone))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `week count follows the month's shape rather than a fixed six rows`() {
|
||||
// June 2026: starts Monday, 30 days → 5 rows.
|
||||
assertThat(layoutMonthWeeks(jun, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(5)
|
||||
// August 2026: starts Saturday, 31 days → spills to 6 rows.
|
||||
val aug = YearMonth(2026, kotlinx.datetime.Month.AUGUST)
|
||||
assertThat(layoutMonthWeeks(aug, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(6)
|
||||
// February 2021: starts Monday, 28 days → exactly 4 rows.
|
||||
val feb = YearMonth(2021, kotlinx.datetime.Month.FEBRUARY)
|
||||
assertThat(layoutMonthWeeks(feb, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `layoutMonthWeeks rows are contiguous and seven days wide`() {
|
||||
val weeks = layoutMonthWeeks(jun, DayOfWeek.MONDAY, emptyList(), zone)
|
||||
assertThat(weeks.first().days.first()).isEqualTo(jun1)
|
||||
weeks.forEach { assertThat(it.days).hasSize(7) }
|
||||
val allDays = weeks.flatMap { it.days }
|
||||
allDays.zipWithNext { a, b -> assertThat(b).isEqualTo(a.plus(1, DateTimeUnit.DAY)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-day event becomes one span across its columns`() {
|
||||
val ev = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12))
|
||||
val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone)
|
||||
|
||||
assertThat(week.spans).hasSize(1)
|
||||
val span = week.spans.single()
|
||||
assertThat(span.startCol).isEqualTo(2) // Wednesday the 10th
|
||||
assertThat(span.endCol).isEqualTo(4) // Friday the 12th
|
||||
assertThat(span.lane).isEqualTo(0)
|
||||
assertThat(span.continuesLeft).isFalse()
|
||||
assertThat(span.continuesRight).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a span running past the row end is flagged as continuing`() {
|
||||
// Saturday the 13th through Tuesday the 16th straddles the row boundary.
|
||||
val ev = allDay(LocalDate(2026, 6, 13), LocalDate(2026, 6, 16))
|
||||
val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone)
|
||||
|
||||
val span = week.spans.single()
|
||||
assertThat(span.startCol).isEqualTo(5)
|
||||
assertThat(span.endCol).isEqualTo(6)
|
||||
assertThat(span.continuesLeft).isFalse()
|
||||
assertThat(span.continuesRight).isTrue()
|
||||
|
||||
// The following row picks it up with the flags mirrored.
|
||||
val nextRow = layoutCalendarWeek(
|
||||
weekOf8th.map { it.plus(7, DateTimeUnit.DAY) },
|
||||
listOf(ev),
|
||||
zone,
|
||||
)
|
||||
val tail = nextRow.spans.single()
|
||||
assertThat(tail.startCol).isEqualTo(0)
|
||||
assertThat(tail.endCol).isEqualTo(1)
|
||||
assertThat(tail.continuesLeft).isTrue()
|
||||
assertThat(tail.continuesRight).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `overlapping spans are stacked on separate lanes`() {
|
||||
val a = allDay(LocalDate(2026, 6, 9), LocalDate(2026, 6, 11), id = 1L)
|
||||
val b = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 2L)
|
||||
val week = layoutCalendarWeek(weekOf8th, listOf(a, b), zone)
|
||||
|
||||
assertThat(week.spans.map { it.lane }).containsExactly(0, 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single-day timed events stay pills and sort by start`() {
|
||||
val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 1L)
|
||||
val early = timed(LocalDate(2026, 6, 10), 9, 10, id = 2L)
|
||||
val week = layoutCalendarWeek(weekOf8th, listOf(late, early), zone)
|
||||
|
||||
assertThat(week.spans).isEmpty()
|
||||
assertThat(week.timedByDay[LocalDate(2026, 6, 10)]?.map { it.instanceId })
|
||||
.containsExactly(2L, 1L)
|
||||
.inOrder()
|
||||
assertThat(week.timedByDay[LocalDate(2026, 6, 11)]).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `countByDay totals bars and pills on each date`() {
|
||||
val span = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L)
|
||||
val pill = timed(LocalDate(2026, 6, 10), 9, 10, id = 2L)
|
||||
val week = layoutCalendarWeek(weekOf8th, listOf(span, pill), zone)
|
||||
|
||||
assertThat(week.countByDay[LocalDate(2026, 6, 10)]).isEqualTo(2)
|
||||
assertThat(week.countByDay[LocalDate(2026, 6, 11)]).isEqualTo(1)
|
||||
assertThat(week.countByDay[LocalDate(2026, 6, 13)]).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `layoutMonthWeeks agrees with laying each row out on its own`() {
|
||||
val events = listOf(
|
||||
allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L),
|
||||
timed(LocalDate(2026, 6, 18), 9, 10, id = 2L),
|
||||
)
|
||||
val weeks = layoutMonthWeeks(jun, DayOfWeek.MONDAY, events, zone)
|
||||
|
||||
weeks.forEach { row ->
|
||||
assertThat(row).isEqualTo(layoutCalendarWeek(row.days, events, zone))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instancesByDay puts all-day events first then orders by start`() {
|
||||
val allDayEv = allDay(LocalDate(2026, 6, 10), id = 1L)
|
||||
val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 2L)
|
||||
val early = timed(LocalDate(2026, 6, 10), 9, 10, id = 3L)
|
||||
|
||||
val byDay = instancesByDay(weekOf8th, listOf(late, early, allDayEv), zone)
|
||||
|
||||
assertThat(byDay.getValue(LocalDate(2026, 6, 10)).map { it.instanceId })
|
||||
.containsExactly(1L, 3L, 2L)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instancesByDay repeats a multi-day event on every date it covers`() {
|
||||
val ev = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12))
|
||||
val byDay = instancesByDay(weekOf8th, listOf(ev), zone)
|
||||
|
||||
assertThat(byDay.getValue(LocalDate(2026, 6, 9))).isEmpty()
|
||||
assertThat(byDay.getValue(LocalDate(2026, 6, 10))).hasSize(1)
|
||||
assertThat(byDay.getValue(LocalDate(2026, 6, 11))).hasSize(1)
|
||||
assertThat(byDay.getValue(LocalDate(2026, 6, 12))).hasSize(1)
|
||||
assertThat(byDay.getValue(LocalDate(2026, 6, 13))).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instancesByDay covers every date in the grid, empty ones included`() {
|
||||
val byDay = instancesByDay(weekOf8th, emptyList(), zone)
|
||||
assertThat(byDay.keys).containsExactlyElementsIn(weekOf8th)
|
||||
assertThat(byDay.values.flatten()).isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class WidgetScaleTest {
|
||||
|
||||
@Test
|
||||
fun `the on-device calibration points map to their tiers`() {
|
||||
// The two sizes measured on-device: the compact widget stays COMPACT (the
|
||||
// baseline, unchanged), the full-width one steps up to LARGE — not XLARGE,
|
||||
// which read as too big on a phone (#51).
|
||||
assertThat(scaleFor(DpSize(222.dp, 270.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||
assertThat(scaleFor(DpSize(378.dp, 672.dp))).isEqualTo(WidgetScale.LARGE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full-width widget at ordinary height still scales up`() {
|
||||
// The regression the height cap used to cause: widening the widget without
|
||||
// also making it unusually tall is *the* resize #51 reports, and it must
|
||||
// reach the tier its width earned. Three cells tall is about 270dp.
|
||||
assertThat(scaleFor(DpSize(378.dp, 270.dp))).isEqualTo(WidgetScale.LARGE)
|
||||
assertThat(scaleFor(DpSize(300.dp, 270.dp))).isEqualTo(WidgetScale.REGULAR)
|
||||
assertThat(scaleFor(DpSize(460.dp, 300.dp))).isEqualTo(WidgetScale.XLARGE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `width buckets into the four tiers`() {
|
||||
// Tall enough that the height cap never binds, isolating the width rule.
|
||||
val h = 500.dp
|
||||
assertThat(scaleFor(DpSize(180.dp, h))).isEqualTo(WidgetScale.COMPACT)
|
||||
assertThat(scaleFor(DpSize(259.dp, h))).isEqualTo(WidgetScale.COMPACT)
|
||||
assertThat(scaleFor(DpSize(260.dp, h))).isEqualTo(WidgetScale.REGULAR)
|
||||
assertThat(scaleFor(DpSize(329.dp, h))).isEqualTo(WidgetScale.REGULAR)
|
||||
assertThat(scaleFor(DpSize(330.dp, h))).isEqualTo(WidgetScale.LARGE)
|
||||
assertThat(scaleFor(DpSize(419.dp, h))).isEqualTo(WidgetScale.LARGE)
|
||||
assertThat(scaleFor(DpSize(420.dp, h))).isEqualTo(WidgetScale.XLARGE)
|
||||
assertThat(scaleFor(DpSize(900.dp, h))).isEqualTo(WidgetScale.XLARGE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extra height never raises the tier`() {
|
||||
// Height decides how many rows are visible, not how big they are: a tall,
|
||||
// narrow widget wants more events, not bigger text.
|
||||
assertThat(scaleFor(DpSize(222.dp, 200.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||
assertThat(scaleFor(DpSize(222.dp, 900.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||
assertThat(scaleFor(DpSize(300.dp, 900.dp))).isEqualTo(WidgetScale.REGULAR)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only a genuinely squashed widget is stepped back down`() {
|
||||
// Same (wide) width, shrinking height. The cap exists to stop oversized type
|
||||
// surviving in a one- or two-row sliver — it must not fire at normal heights.
|
||||
// Heights are gross; the cap works on height minus 60dp of chrome, so the
|
||||
// LARGE floor is 190dp (130dp usable) and the REGULAR floor 130dp (70dp).
|
||||
val wide = 378.dp
|
||||
assertThat(scaleFor(DpSize(wide, 400.dp))).isEqualTo(WidgetScale.LARGE)
|
||||
assertThat(scaleFor(DpSize(wide, 260.dp))).isEqualTo(WidgetScale.LARGE)
|
||||
assertThat(scaleFor(DpSize(wide, 190.dp))).isEqualTo(WidgetScale.LARGE)
|
||||
assertThat(scaleFor(DpSize(wide, 189.dp))).isEqualTo(WidgetScale.REGULAR)
|
||||
assertThat(scaleFor(DpSize(wide, 130.dp))).isEqualTo(WidgetScale.REGULAR)
|
||||
assertThat(scaleFor(DpSize(wide, 129.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||
// The provider's declared floor (minResizeWidth/Height = 110dp) is COMPACT.
|
||||
assertThat(scaleFor(DpSize(110.dp, 110.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the tier is monotonic in both axes`() {
|
||||
// Growing a widget must never make its type smaller. Sweeps the whole
|
||||
// plausible range rather than spot-checking, so a future threshold edit
|
||||
// can't accidentally invert a step.
|
||||
val widths = (110..900 step 7).map { it.dp }
|
||||
val heights = (110..900 step 7).map { it.dp }
|
||||
widths.forEach { w ->
|
||||
heights.zipWithNext { shorter, taller ->
|
||||
assertThat(scaleFor(DpSize(w, taller)))
|
||||
.isAtLeast(scaleFor(DpSize(w, shorter)))
|
||||
}
|
||||
}
|
||||
heights.forEach { h ->
|
||||
widths.zipWithNext { narrower, wider ->
|
||||
assertThat(scaleFor(DpSize(wider, h)))
|
||||
.isAtLeast(scaleFor(DpSize(narrower, h)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package de.jeanlucmakiola.calendula.widget.agenda
|
||||
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetScale
|
||||
import de.jeanlucmakiola.calendula.widget.scaleFor
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AgendaScaleTest {
|
||||
|
||||
// --- the "default size is unchanged" regression guard ---------------------
|
||||
|
||||
@Test
|
||||
fun `every default placement width stays COMPACT`() {
|
||||
// Ties the guarantee to the provider XML's declared 3-cell default rather
|
||||
// than to one measured launcher: the whole band a default placement can
|
||||
// land in must bucket to COMPACT, or a freshly placed widget silently
|
||||
// changes appearance (#51). See AGENDA_DEFAULT_WIDTH_BAND.
|
||||
val band = AGENDA_DEFAULT_WIDTH_BAND
|
||||
var w = band.start
|
||||
while (w <= band.endInclusive) {
|
||||
assertThat(scaleFor(DpSize(w, 270.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||
w += 1.dp
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `COMPACT metrics equal the widget's original constants`() {
|
||||
// If this fails, a default-sized agenda widget no longer looks as it did.
|
||||
val m = metricsFor(WidgetScale.COMPACT)
|
||||
assertThat(m.title).isEqualTo(16.sp)
|
||||
assertThat(m.dayHeader).isEqualTo(13.sp)
|
||||
assertThat(m.eventTitle).isEqualTo(14.sp)
|
||||
assertThat(m.eventTime).isEqualTo(12.sp)
|
||||
assertThat(m.placeholder).isEqualTo(14.sp)
|
||||
assertThat(m.message).isEqualTo(14.sp)
|
||||
assertThat(m.stripeH).isEqualTo(36.dp)
|
||||
assertThat(m.iconImage).isEqualTo(22.dp)
|
||||
assertThat(m.iconBox).isEqualTo(40.dp)
|
||||
assertThat(m.rowVPad).isEqualTo(4.dp)
|
||||
assertThat(m.dayHeaderTopPad).isEqualTo(10.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the derived text indent matches the original hardcoded inset`() {
|
||||
// TEXT_INDENT replaced a hardcoded 19dp; it must still resolve to 19dp or
|
||||
// day headers and the placeholder line stop aligning with event titles.
|
||||
assertThat(TEXT_INDENT).isEqualTo(19.dp)
|
||||
assertThat(TEXT_INDENT).isEqualTo(ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP)
|
||||
}
|
||||
|
||||
// --- the ramp ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `sizes are non-decreasing across the tiers`() {
|
||||
val tiers = WidgetScale.entries.map(::metricsFor)
|
||||
|
||||
tiers.zipWithNext { small, big ->
|
||||
assertThat(big.title.value).isAtLeast(small.title.value)
|
||||
assertThat(big.dayHeader.value).isAtLeast(small.dayHeader.value)
|
||||
assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value)
|
||||
assertThat(big.eventTime.value).isAtLeast(small.eventTime.value)
|
||||
assertThat(big.placeholder.value).isAtLeast(small.placeholder.value)
|
||||
assertThat(big.message.value).isAtLeast(small.message.value)
|
||||
assertThat(big.stripeH.value).isAtLeast(small.stripeH.value)
|
||||
assertThat(big.iconImage.value).isAtLeast(small.iconImage.value)
|
||||
assertThat(big.iconBox.value).isAtLeast(small.iconBox.value)
|
||||
assertThat(big.rowVPad.value).isAtLeast(small.rowVPad.value)
|
||||
assertThat(big.dayHeaderTopPad.value).isAtLeast(small.dayHeaderTopPad.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the event title keeps its lead over the time line`() {
|
||||
// The secondary line steps more slowly on purpose; if it ever caught up the
|
||||
// row would lose its hierarchy.
|
||||
WidgetScale.entries.map(::metricsFor).forEach { m ->
|
||||
assertThat(m.eventTitle.value).isGreaterThan(m.eventTime.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no tier grows type more than half again over the baseline`() {
|
||||
// Guards against a future edit turning "more readable" into "absurd".
|
||||
val base = metricsFor(WidgetScale.COMPACT)
|
||||
val top = metricsFor(WidgetScale.XLARGE)
|
||||
assertThat(top.title.value / base.title.value).isLessThan(1.5f)
|
||||
assertThat(top.eventTitle.value / base.eventTitle.value).isLessThan(1.5f)
|
||||
}
|
||||
|
||||
// --- font scale ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the stripe tracks the system font scale`() {
|
||||
// The stripe is Dp, the text beside it is sp: without this the two diverge
|
||||
// at large accessibility font settings and the stripe under-runs the row.
|
||||
val m = metricsFor(WidgetScale.COMPACT)
|
||||
assertThat(m.scaledForFont(1f).stripeH).isEqualTo(36.dp)
|
||||
assertThat(m.scaledForFont(1.3f).stripeH.value).isWithin(0.01f).of(46.8f)
|
||||
assertThat(m.scaledForFont(0.85f).stripeH.value).isWithin(0.01f).of(30.6f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scaling for the default font scale changes nothing`() {
|
||||
val m = metricsFor(WidgetScale.LARGE)
|
||||
assertThat(m.scaledForFont(1f)).isSameInstanceAs(m)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `font scaling leaves the sp sizes alone`() {
|
||||
// Glance already applies the font scale to sp; scaling them here too would
|
||||
// double-count it.
|
||||
val m = metricsFor(WidgetScale.REGULAR)
|
||||
val scaled = m.scaledForFont(1.3f)
|
||||
assertThat(scaled.title).isEqualTo(m.title)
|
||||
assertThat(scaled.eventTitle).isEqualTo(m.eventTitle)
|
||||
assertThat(scaled.rowVPad).isEqualTo(m.rowVPad)
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,30 @@ on-device — see plan 03):
|
||||
UTC, so zones ahead of UTC can't leak an extra occurrence.
|
||||
- All-day events are normalised to UTC midnights with an exclusive end.
|
||||
|
||||
### Event time zones
|
||||
|
||||
`EventForm.timezone` is the zone its wall-clock times mean, and **null means
|
||||
"the device zone at save time"** — not "no zone". The data layer resolves it in
|
||||
`toWriteTimes` and always stamps a concrete `EVENT_TIMEZONE`, so an ordinary
|
||||
event behaves exactly as it did before the field existed.
|
||||
|
||||
- A non-null value **pins** the event: it keeps tracking that zone's offset
|
||||
across DST no matter where the device is. `toEditForm` only pins when the
|
||||
stored zone differs from the device's, so the optional Time-zone field stays
|
||||
hidden on ordinary events and reveals itself (via `populatedFields`) on
|
||||
foreign-zone ones.
|
||||
- A pinned event is prefilled **in its own zone**, so the form shows the
|
||||
wall-clock the event means rather than the device's rendering of it.
|
||||
- A zone change counts as a **time change** even with the wall-clock untouched
|
||||
(same 09:00 elsewhere is a different instant), so `buildEventUpdateValues`
|
||||
includes it in `timesChanged` and rewrites `DTSTART`.
|
||||
- **All-day events never carry a zone.** They're date-anchored — the UTC
|
||||
midnights above are an anchor, not a location — so the field is withheld from
|
||||
the form entirely and `toWriteTimes` forces `"UTC"` regardless.
|
||||
|
||||
Still device-zone-relative, and knowingly so: `RRULE`'s `UNTIL` rendering and
|
||||
`AllDayReminderEncoding`'s offset (see its KDoc).
|
||||
|
||||
## Save conflicts
|
||||
|
||||
No locking. `openForEdit` keeps an `EditSnapshot` — the prefilled form
|
||||
|
||||
@@ -82,9 +82,24 @@ release work when a merge actually cuts a release:
|
||||
merged commit, build & sign the release APK with the **app key**, copy it into
|
||||
the F-Droid repo, generate the per-version changelog, re-sign the index with
|
||||
the **repo key**, upload `repo/` + `metadata/`, then create the `vX.Y.Z` tag +
|
||||
Gitea release (CHANGELOG section as notes) and attach the R8 `mapping.txt`
|
||||
(best-effort). Ordinary merges with no version bump fall through `detect` and
|
||||
do nothing.
|
||||
Gitea release (CHANGELOG section as notes), attach the R8 `mapping.txt`, and
|
||||
mirror the release to **Codeberg** with the signed APK + a SHA-256 checksum
|
||||
(both best-effort). Ordinary merges with no version bump fall through `detect`
|
||||
and do nothing.
|
||||
|
||||
### Codeberg direct-download channel
|
||||
|
||||
Alongside F-Droid, each release is mirrored to the Codeberg repo
|
||||
(`jlmakiola/calendula`) as a plain download for users who don't want F-Droid.
|
||||
Gitea already **push-mirrors** branches and tags to Codeberg, but releases
|
||||
aren't git objects and don't sync, so the pipeline creates the release over the
|
||||
Codeberg API and attaches `calendula_v<version>.apk` + its `.sha256`. It's the
|
||||
same APK the F-Droid repo serves (same **app key**), so it adds no trust
|
||||
surface. The step is best-effort: a Codeberg outage never fails an
|
||||
already-published F-Droid release, and it skips cleanly if `CODEBERG_RELEASE_TOKEN` is
|
||||
unset. One-time setup: the repo's **Releases** unit must be enabled and a
|
||||
`CODEBERG_RELEASE_TOKEN` secret (Codeberg access token, `write:repository` scope) added
|
||||
to Gitea Actions.
|
||||
|
||||
### Manual re-sign / recovery
|
||||
|
||||
@@ -103,6 +118,7 @@ for key rotation or repo recovery without publishing a new app version.
|
||||
| `FDROID_CONFIG_BASE64` | F-Droid `config.yml` (base64) — repo metadata + keystore passwords. |
|
||||
| `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | Upload target for the F-Droid repo. |
|
||||
| `GITHUB_TOKEN` | Provided by Gitea Actions; used to create the release + attach assets. |
|
||||
| `CODEBERG_RELEASE_TOKEN` | Codeberg access token (`write:repository` scope) — creates the mirrored Codeberg release + uploads the APK/checksum. Best-effort; if unset the Codeberg step skips. |
|
||||
|
||||
The two keys are independent: the **app key** signs APKs; the **repo key**
|
||||
signs the index (its fingerprint is what users pin). Neither key nor the
|
||||
|
||||
101
fastlane/metadata/android/en-US/changelogs/21600.txt
Normal file
101
fastlane/metadata/android/en-US/changelogs/21600.txt
Normal file
@@ -0,0 +1,101 @@
|
||||
### Added
|
||||
- Choose how the month view is laid out. A new **Month view style** setting
|
||||
(Settings → Views) offers three ways to read a month, each shown with a
|
||||
preview of the layout it produces:
|
||||
- **Pages** — what you have today: one month at a time, swiped sideways.
|
||||
- **Continuous** — scroll up and down through the weeks without a break
|
||||
between months. Because the weeks run on unbroken, no month is cut off and
|
||||
no day appears twice, where paging repeats a boundary week at the end of one
|
||||
month and the start of the next. The 1st of each month names itself so you
|
||||
always know where you are, and the title bar keeps up as you scroll ([#38]).
|
||||
- **Split** — a compact grid showing coloured dots for the days that have
|
||||
something on them, with the day you tap listed in full underneath. Tap the
|
||||
date above the list to open the whole day ([#53]).
|
||||
|
||||
The Agenda view is untouched by this and stays available in all three styles —
|
||||
the split layout lists a single day, while Agenda remains a rolling multi-day
|
||||
window with its own range settings.
|
||||
- Give an event its own time zone. A new **Time zone** field (under "more
|
||||
fields" in the event form) pins an event to a specific zone, so a call set for
|
||||
8:00 AM in New York stays 8:00 AM in New York wherever you open it — and keeps
|
||||
tracking that zone across daylight-saving changes instead of drifting an hour.
|
||||
The form edits the event in its own zone and shows the local equivalent under
|
||||
the times ("2:00 PM – 3:00 PM your time"); the event's details keep your local
|
||||
time first and note the original beneath it, so both are always clear. Pick a
|
||||
zone from a full-screen picker with your device zone and recent choices on top,
|
||||
searching by city ("new york"), IANA id ("europe/berlin"), or abbreviation
|
||||
("CEST") to gather every matching zone at once. All-day events stay
|
||||
date-anchored and carry no zone, as before ([#31]).
|
||||
- Put the "jump to today" button in the toolbar. A new **Today button in
|
||||
toolbar** setting (Settings → Appearance, off by default) swaps the floating
|
||||
button that fades into the corner while you're away from today for a permanent
|
||||
today icon in the top bar — always there, on today or not, matching the
|
||||
familiar calendar-app pattern. Leave it off to keep the floating button as
|
||||
before ([#60]).
|
||||
- Choose what Calendula calls itself on your home screen. A new **App name**
|
||||
setting (Settings → Appearance) switches the launcher label between
|
||||
**Calendula** and **Calendar**, for launchers that can't rename apps
|
||||
themselves. Pick from a full-screen chooser that previews both names as
|
||||
launcher marks; the change applies at once ([#44]).
|
||||
- Calendula now speaks **Arabic**, laid out right-to-left, and its French and
|
||||
Italian translations have been brought up to date — thanks to the community
|
||||
translators on Weblate. Pick a language under Settings → Language (or leave it
|
||||
on the system default).
|
||||
|
||||
### Changed
|
||||
- Dates in the Month, Week and Day title bars now follow your language and
|
||||
region instead of one hardcoded layout. Every date was rendered in a fixed
|
||||
German-style order with a trailing dot on the day number, whatever your
|
||||
settings: US English showed "Fri, 17. Jul 2026" where it should read
|
||||
"Fri, Jul 17". The Agenda view already formatted correctly, so the two
|
||||
disagreed about the same date. All four views now share one formatter, and the
|
||||
day/month order, the separators and the ordinal all come from your locale —
|
||||
so English-in-Germany reads "Fri, 17 Jul" and English-in-the-US "Fri, Jul 17",
|
||||
each correct for where you are ([#60]).
|
||||
- The title bar drops the year while you're in the current one — "July" rather
|
||||
than "July 2026". The year reappears the moment you page out of the current
|
||||
year, which is when it tells you something you didn't already know.
|
||||
- The Week view's title now names the month instead of spelling out the day range.
|
||||
"24. Jun – 31. Jun" restated the day numbers already printed in the column
|
||||
headers right below it, in the widest string in the bar. A week that straddles
|
||||
two months keeps the outgoing month until it is fully gone ([#60]).
|
||||
- The custom recurrence picker has been redesigned and tightened up. As you
|
||||
build a rule — "every 2 weeks on Mon & Wed, until a date" — the live summary
|
||||
now describes exactly what will be saved rather than a near-copy that could
|
||||
drift from it, the amount fields accept being left blank (reading as their
|
||||
shown default instead of greying out OK), and the read-out no longer jumps
|
||||
around as you tap weekdays ([#42]).
|
||||
|
||||
### Fixed
|
||||
- An all-day event no longer shows up again the day after it happened. In time
|
||||
zones east of UTC, an all-day event — a birthday, say — set for one day also
|
||||
appeared under the *next* day's heading in the Agenda (and the agenda widget),
|
||||
because all-day events are anchored to UTC midnight and the following day's
|
||||
window reached back across that boundary and pulled the event forward onto
|
||||
"today". Each all-day event now lists only on the day it actually falls on
|
||||
([#65]).
|
||||
- A multi-day event now shows under every day it spans in the Agenda, not just
|
||||
its first day, so a trip or a multi-day booking appears on each day it covers.
|
||||
- The "Upcoming" agenda widget now scales its text and rows to the size you give
|
||||
it. Previously it was laid out once for the smallest size and simply stretched
|
||||
when enlarged, so the text stayed small no matter how big you made the widget.
|
||||
Now a bigger widget gets bigger, more readable type and roomier rows, while the
|
||||
default size looks exactly as before — no new setting; it follows the size you
|
||||
already chose ([#51]).
|
||||
- Calendula now appears under other apps' "Add to calendar" / "Save to calendar"
|
||||
actions. Some apps (e.g. DB Navigator) fire the widely-used "insert event"
|
||||
intent with the singular `vnd.android.cursor.item/event` type, which Calendula
|
||||
didn't advertise — so it was left out of the chooser, and if it was your only
|
||||
calendar app the save silently did nothing. It now accepts that form, plus the
|
||||
`INSERT_OR_EDIT` action, and opens the new event prefilled for review ([#74]).
|
||||
- Opening a `.ics`/`.vcs` file now works even when another app hands it over
|
||||
mislabelled as a generic download (`application/octet-stream`), as some mail
|
||||
clients, browsers and file managers do — Calendula recognises it by its file
|
||||
extension instead of relying on the declared type ([#74]).
|
||||
- A recurrence end date no longer lands a day late. West of UTC, setting a rule
|
||||
to end "until" a given day could save and show the day after the one picked;
|
||||
the end date now reads back as chosen ([#42]).
|
||||
- The status- and navigation-bar icons stay legible over full-screen pickers in
|
||||
dark theme. They could render dark-on-dark — a near-invisible black clock
|
||||
against the dark picker — instead of switching to light ([#70]).
|
||||
|
||||
Submodule floret-kit updated: 2124227a7f...b4d3ead8f0
Reference in New Issue
Block a user