Compare commits
84 Commits
main
...
release/v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 7d357bad87 | |||
| 3ffe76412e | |||
| 91c4a84818 | |||
| ccd433fee1 | |||
| 03f6b7c8c1 | |||
| 426ddf27ee | |||
| 4148196a36 | |||
| 9a1d753abc | |||
| 1b29abed17 | |||
| 85d72ad051 | |||
| fd3bcdf2f7 | |||
| 46afa830b3 | |||
| 7de9b2f81b | |||
| 3ec46c8631 | |||
| 529dc9c374 | |||
| 60ef920b82 | |||
| 7b7b859fee | |||
| b5c2930609 | |||
| 3feafe38f2 | |||
| 19baed9291 | |||
| 4ea68adf8b | |||
| c3ba8ccf64 | |||
| 7ed1d89b66 | |||
| 734bcae02d | |||
| 629896b74d | |||
| 01ac61185b | |||
| ca01f6e729 | |||
| 0588609c75 | |||
| ab3df639b9 | |||
| fefe1f3652 | |||
| 7634df3cff | |||
| 60938af9f0 | |||
| accf0ac142 | |||
| f90badfcd5 | |||
| 249606a358 |
@@ -1,13 +1,11 @@
|
|||||||
name: Release — F-Droid repo + Gitea/Codeberg release
|
name: Release — F-Droid repo + Gitea release
|
||||||
|
|
||||||
# A release is cut by merging a release branch into main with a bumped
|
# 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,
|
# versionName (see docs/RELEASING.md). This workflow reads that versionName and,
|
||||||
# if no matching tag exists yet, runs tests, builds + signs the APK, publishes
|
# if no matching tag exists yet, runs tests, builds + signs the APK, publishes
|
||||||
# it to the F-Droid repo, creates the vX.Y.Z tag + Gitea release, and mirrors
|
# it to the F-Droid repo, and only then creates the vX.Y.Z tag + Gitea release
|
||||||
# that release to Codeberg with the signed APK + a SHA-256 checksum as a
|
# itself — the tag is an output of the pipeline, not its trigger. Ordinary
|
||||||
# direct-download channel — the tag is an output of the pipeline, not its
|
# merges (no version bump) fall through `detect` and do nothing.
|
||||||
# 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
|
# 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,
|
# path: it re-signs the existing F-Droid index with the repo key and re-uploads,
|
||||||
@@ -352,88 +350,3 @@ jobs:
|
|||||||
curl -s -X POST -H "Authorization: token $TOKEN" \
|
curl -s -X POST -H "Authorization: token $TOKEN" \
|
||||||
-F "attachment=@/tmp/$ASSET" \
|
-F "attachment=@/tmp/$ASSET" \
|
||||||
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
|
"$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."
|
|
||||||
|
|||||||
82
CHANGELOG.md
82
CHANGELOG.md
@@ -5,6 +5,82 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- 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]).
|
||||||
|
|
||||||
|
## [2.16.0] — 2026-07-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- 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]).
|
||||||
|
|
||||||
|
### 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]).
|
||||||
|
|
||||||
|
### 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]).
|
||||||
|
|
||||||
## [2.15.0] — 2026-07-15
|
## [2.15.0] — 2026-07-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -984,6 +1060,7 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
|
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
|
||||||
[#21]: https://codeberg.org/jlmakiola/calendula/issues/21
|
[#21]: https://codeberg.org/jlmakiola/calendula/issues/21
|
||||||
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
|
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
|
||||||
|
[#31]: https://codeberg.org/jlmakiola/calendula/issues/31
|
||||||
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32
|
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32
|
||||||
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
||||||
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
||||||
@@ -996,4 +1073,9 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
|
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
|
||||||
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
|
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
|
||||||
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
||||||
|
[#51]: https://codeberg.org/jlmakiola/calendula/issues/51
|
||||||
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52
|
[#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
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 21500
|
versionCode = 21600
|
||||||
versionName = "2.15.0"
|
versionName = "2.16.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.test.platform.app.InstrumentationRegistry
|
|||||||
import androidx.test.rule.GrantPermissionRule
|
import androidx.test.rule.GrantPermissionRule
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -31,7 +32,12 @@ class CalendarRepositorySmokeTest {
|
|||||||
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||||
produceFile = { context.cacheDir.resolve("smoke_test_prefs.preferences_pb") },
|
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
|
@Test
|
||||||
|
|||||||
@@ -63,10 +63,9 @@
|
|||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<!-- The MAIN/LAUNCHER entry (the launcher icon + its label) lives on
|
||||||
<action android:name="android.intent.action.MAIN" />
|
the two <activity-alias> below, so the app name can be switched at
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
runtime (issue #44). MainActivity keeps every other filter. -->
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<!-- Be selectable as the system calendar app. Android has no API for
|
<!-- Be selectable as the system calendar app. Android has no API for
|
||||||
an app to make itself the default, so registering the filters
|
an app to make itself the default, so registering the filters
|
||||||
@@ -165,11 +164,47 @@
|
|||||||
<data android:mimeType="vnd.android.cursor.item/event" />
|
<data android:mimeType="vnd.android.cursor.item/event" />
|
||||||
</intent-filter>
|
</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
|
<meta-data
|
||||||
android:name="android.app.shortcuts"
|
android:name="android.app.shortcuts"
|
||||||
android:resource="@xml/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
|
<!-- Standalone surface for a captured crash report. MainActivity routes
|
||||||
here on a startup crash-loop, so it stays clear of the app's Hilt
|
here on a startup crash-loop, so it stays clear of the app's Hilt
|
||||||
|
|||||||
@@ -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 de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toJavaLocalDateTime
|
import kotlinx.datetime.toJavaLocalDateTime
|
||||||
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
|
import java.time.LocalDateTime as JavaLocalDateTime
|
||||||
|
|
||||||
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
||||||
internal data class EventWriteTimes(
|
internal data class EventWriteTimes(
|
||||||
@@ -20,7 +22,11 @@ internal data class EventWriteTimes(
|
|||||||
/**
|
/**
|
||||||
* All-day events live at UTC midnights with an exclusive DTEND (the
|
* All-day events live at UTC midnights with an exclusive DTEND (the
|
||||||
* CalendarContract convention — a one-day event ends at the next midnight);
|
* 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) {
|
internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) {
|
||||||
EventWriteTimes(
|
EventWriteTimes(
|
||||||
@@ -31,13 +37,38 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
|||||||
timezone = "UTC",
|
timezone = "UTC",
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
val writeZone = writeZone(zone)
|
||||||
EventWriteTimes(
|
EventWriteTimes(
|
||||||
dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||||
dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||||
timezone = zone.id,
|
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
|
* 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
|
* 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):
|
* Time fields travel together (the provider validates them as a unit):
|
||||||
* - unchanged times, all-day flag and rrule → no time columns at all;
|
* - unchanged times, all-day flag and rrule → no time columns at all;
|
||||||
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
|
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
|
||||||
* - recurring result → the *series* DTSTART moves by the same delta the user
|
* - recurring result → the *series* DTSTART moves by the same **wall-clock**
|
||||||
* applied to the displayed occurrence ([seriesDtStartMillis] is the row's
|
* shift the user applied to the displayed occurrence and is re-resolved in the
|
||||||
* current DTSTART), DURATION replaces DTEND, RRULE is written. This keeps
|
* event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION
|
||||||
* past occurrences intact when someone edits a later occurrence's time.
|
* 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(
|
internal fun buildEventUpdateValues(
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
@@ -134,10 +167,14 @@ internal fun buildEventUpdateValues(
|
|||||||
putAll(eventColorColumns(updated.colorKey, updated.color))
|
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 ||
|
val timesChanged = updated.start != original.start ||
|
||||||
updated.end != original.end ||
|
updated.end != original.end ||
|
||||||
updated.isAllDay != original.isAllDay ||
|
updated.isAllDay != original.isAllDay ||
|
||||||
updated.rrule != original.rrule
|
updated.rrule != original.rrule ||
|
||||||
|
updated.timezone != original.timezone
|
||||||
if (!timesChanged) return@buildMap
|
if (!timesChanged) return@buildMap
|
||||||
|
|
||||||
val newTimes = updated.toWriteTimes(zone)
|
val newTimes = updated.toWriteTimes(zone)
|
||||||
@@ -149,8 +186,23 @@ internal fun buildEventUpdateValues(
|
|||||||
put(CalendarContract.Events.RRULE, null)
|
put(CalendarContract.Events.RRULE, null)
|
||||||
put(CalendarContract.Events.DURATION, null)
|
put(CalendarContract.Events.DURATION, null)
|
||||||
} else {
|
} else {
|
||||||
val startDelta = newTimes.dtStartMillis - original.toWriteTimes(zone).dtStartMillis
|
// Move the series anchor by the *wall-clock* shift the user applied to the
|
||||||
put(CalendarContract.Events.DTSTART, seriesDtStartMillis + startDelta)
|
// 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.DTEND, null)
|
||||||
put(CalendarContract.Events.RRULE, updated.rrule)
|
put(CalendarContract.Events.RRULE, updated.rrule)
|
||||||
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
|
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.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
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.Flow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import java.time.temporal.WeekFields
|
import java.time.temporal.WeekFields
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -81,6 +87,24 @@ fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
|
|||||||
WeekStartPref.Auto -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
|
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
|
* Display settings (M4) persisted app-side: theme override, Material You
|
||||||
* dynamic colour, and week start. Language is handled separately through
|
* 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 }
|
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
|
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
||||||
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
* [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
|
* 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
|
* 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) =
|
private fun titleTemplateKey(type: SpecialDateType) =
|
||||||
stringPreferencesKey("special_dates_title_${type.name}")
|
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) {
|
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||||
null -> DEFAULT_FORM_FIELDS
|
null -> DEFAULT_FORM_FIELDS
|
||||||
else -> stored.split(',')
|
else -> stored.split(',')
|
||||||
@@ -710,6 +788,8 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||||
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||||
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
|
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 DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||||
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||||
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||||
@@ -736,6 +816,10 @@ class SettingsPrefs @Inject constructor(
|
|||||||
stringPreferencesKey("per_calendar_allday_reminder_override")
|
stringPreferencesKey("per_calendar_allday_reminder_override")
|
||||||
internal val DEFAULT_FORM_FIELDS =
|
internal val DEFAULT_FORM_FIELDS =
|
||||||
setOf(EventFormField.Location, EventFormField.Description)
|
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_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled")
|
||||||
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
|
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
|
||||||
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")
|
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.LocalDateTime
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User input for creating an event (and, from v1.3, editing one). Times are
|
* 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
|
* wall-clock values in [timezone]; the data layer translates them to provider
|
||||||
* provider millis (all-day events normalise to UTC midnights there).
|
* millis (all-day events normalise to UTC midnights there).
|
||||||
*/
|
*/
|
||||||
data class EventForm(
|
data class EventForm(
|
||||||
val calendarId: Long?,
|
val calendarId: Long?,
|
||||||
@@ -18,6 +19,23 @@ data class EventForm(
|
|||||||
val isAllDay: Boolean = false,
|
val isAllDay: Boolean = false,
|
||||||
val start: LocalDateTime,
|
val start: LocalDateTime,
|
||||||
val end: 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 location: String = "",
|
||||||
val description: String = "",
|
val description: String = "",
|
||||||
/** Reminder lead times in minutes before the start, deduplicated. */
|
/** 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 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 {
|
enum class EventFormField {
|
||||||
Location,
|
Location,
|
||||||
Description,
|
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,
|
Reminders,
|
||||||
Recurrence,
|
Recurrence,
|
||||||
Availability,
|
Availability,
|
||||||
@@ -100,8 +126,25 @@ enum class EventFormProblem {
|
|||||||
* All-day provider times are UTC midnights with an exclusive end; the form
|
* 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
|
* shows the last covered day and keeps placeholder wall-clock times in case
|
||||||
* the user switches the event to timed.
|
* 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 {
|
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 (start, end) = if (instance.isAllDay) {
|
||||||
val startDate = Instant.fromEpochMilliseconds(beginMillis)
|
val startDate = Instant.fromEpochMilliseconds(beginMillis)
|
||||||
.toLocalDateTime(TimeZone.UTC).date
|
.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))
|
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||||
} else {
|
} else {
|
||||||
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(zone) to
|
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to
|
||||||
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone)
|
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
|
||||||
}
|
}
|
||||||
return EventForm(
|
return EventForm(
|
||||||
calendarId = instance.calendarId,
|
calendarId = instance.calendarId,
|
||||||
@@ -119,6 +162,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
|||||||
isAllDay = instance.isAllDay,
|
isAllDay = instance.isAllDay,
|
||||||
start = start,
|
start = start,
|
||||||
end = end,
|
end = end,
|
||||||
|
timezone = pinnedZone,
|
||||||
location = instance.location.orEmpty(),
|
location = instance.location.orEmpty(),
|
||||||
description = description.orEmpty(),
|
description = description.orEmpty(),
|
||||||
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
||||||
@@ -176,6 +220,23 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
|||||||
rowEnd = instance.end,
|
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
|
* 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
|
* 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 {
|
fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
||||||
if (location.isNotBlank()) add(EventFormField.Location)
|
if (location.isNotBlank()) add(EventFormField.Location)
|
||||||
if (description.isNotBlank()) add(EventFormField.Description)
|
if (description.isNotBlank()) add(EventFormField.Description)
|
||||||
|
if (timezone != null) add(EventFormField.Timezone)
|
||||||
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
|
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
|
||||||
if (rrule != null) add(EventFormField.Recurrence)
|
if (rrule != null) add(EventFormField.Recurrence)
|
||||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
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.
|
// sensible non-empty initial values, so they're ready before the first frame.
|
||||||
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
|
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
|
||||||
val drawerViewOrder = viewModel.drawerViewOrder.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) {
|
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
||||||
mutableStateOf(listOf(defaultView))
|
mutableStateOf(listOf(defaultView))
|
||||||
@@ -318,6 +320,7 @@ fun CalendarHost(
|
|||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
drawerViewOrder = drawerViewOrder,
|
drawerViewOrder = drawerViewOrder,
|
||||||
|
todayInToolbar = todayInToolbar,
|
||||||
)
|
)
|
||||||
CalendarView.Day -> DayScreen(
|
CalendarView.Day -> DayScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
@@ -329,16 +332,19 @@ fun CalendarHost(
|
|||||||
initialDateIso = pendingDayIso,
|
initialDateIso = pendingDayIso,
|
||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
drawerViewOrder = drawerViewOrder,
|
drawerViewOrder = drawerViewOrder,
|
||||||
|
todayInToolbar = todayInToolbar,
|
||||||
)
|
)
|
||||||
CalendarView.Month -> MonthScreen(
|
CalendarView.Month -> MonthScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
drawerViewOrder = drawerViewOrder,
|
drawerViewOrder = drawerViewOrder,
|
||||||
|
todayInToolbar = todayInToolbar,
|
||||||
)
|
)
|
||||||
CalendarView.Agenda -> AgendaScreen(
|
CalendarView.Agenda -> AgendaScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
@@ -350,6 +356,7 @@ fun CalendarHost(
|
|||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
drawerViewOrder = drawerViewOrder,
|
drawerViewOrder = drawerViewOrder,
|
||||||
|
todayInToolbar = todayInToolbar,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,4 +45,12 @@ class CalendarHostViewModel @Inject constructor(
|
|||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = IMPLEMENTED_VIEWS,
|
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
|
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.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import java.time.YearMonth
|
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
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Coffee
|
import androidx.compose.material.icons.filled.Coffee
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -32,7 +27,6 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
@@ -46,8 +40,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
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.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
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.calendula.ui.common.agendaRangeLabel
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
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.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
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.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
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.formatTimeOfDay
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.plus
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Instant
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -102,6 +85,7 @@ fun AgendaScreen(
|
|||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
todayInToolbar: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: AgendaViewModel = hiltViewModel(),
|
viewModel: AgendaViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -151,12 +135,14 @@ fun AgendaScreen(
|
|||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
|
showTodayButton = todayInToolbar,
|
||||||
|
onToday = viewModel::goToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
CalendarFabColumn(
|
CalendarFabColumn(
|
||||||
todayVisible = !isOnToday,
|
todayVisible = !isOnToday && !todayInToolbar,
|
||||||
todayText = stringResource(R.string.agenda_today_action),
|
todayText = stringResource(R.string.agenda_today_action),
|
||||||
onToday = viewModel::goToToday,
|
onToday = viewModel::goToToday,
|
||||||
onCreate = { onCreateEvent(anchor, null) },
|
onCreate = { onCreateEvent(anchor, null) },
|
||||||
@@ -328,6 +314,7 @@ private fun AgendaContent(
|
|||||||
AgendaList(
|
AgendaList(
|
||||||
days = days,
|
days = days,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
|
zone = state.zone,
|
||||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||||
now = now,
|
now = now,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
@@ -344,6 +331,7 @@ private fun AgendaContent(
|
|||||||
private fun AgendaList(
|
private fun AgendaList(
|
||||||
days: List<AgendaDay>,
|
days: List<AgendaDay>,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
dimPast: Boolean,
|
dimPast: Boolean,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
@@ -362,15 +350,23 @@ private fun AgendaList(
|
|||||||
if (day.events.isEmpty()) {
|
if (day.events.isEmpty()) {
|
||||||
// An anchored, event-less today (#35) — "nothing left today".
|
// An anchored, event-less today (#35) — "nothing left today".
|
||||||
item(key = "placeholder-${day.date}") {
|
item(key = "placeholder-${day.date}") {
|
||||||
AgendaEmptyDayRow(onClick = { onOpenDay(day.date) })
|
AgendaEmptyDayRow(
|
||||||
|
text = stringResource(R.string.agenda_no_more_today),
|
||||||
|
onClick = { onOpenDay(day.date) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = day.events,
|
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 ->
|
) { index, event ->
|
||||||
AgendaEventRow(
|
AgendaEventRow(
|
||||||
event = event,
|
event = event,
|
||||||
|
day = day.date,
|
||||||
|
zone = zone,
|
||||||
position = positionOf(index, day.events.size),
|
position = positionOf(index, day.events.size),
|
||||||
dimmed = dimPast && event.hasEnded(now),
|
dimmed = dimPast && event.hasEnded(now),
|
||||||
modifier = animateItemMotion(),
|
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
|
@Composable
|
||||||
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||||
Column(
|
Column(
|
||||||
@@ -504,6 +408,8 @@ private fun AgendaTopBar(
|
|||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
|
showTodayButton: Boolean,
|
||||||
|
onToday: () -> Unit,
|
||||||
scrollBehavior: TopAppBarScrollBehavior,
|
scrollBehavior: TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -522,6 +428,7 @@ private fun AgendaTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
TodayAction(show = showTodayButton, onToday = onToday)
|
||||||
IconButton(onClick = onOpenSearch) {
|
IconButton(onClick = onOpenSearch) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Search,
|
imageVector = Icons.Default.Search,
|
||||||
@@ -541,42 +448,3 @@ private fun AgendaTopBar(
|
|||||||
scrollBehavior = scrollBehavior,
|
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.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toLocalDateTime
|
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. */
|
/** One calendar day with at least one event, for the agenda list. */
|
||||||
data class AgendaDay(
|
data class AgendaDay(
|
||||||
@@ -15,22 +76,41 @@ data class AgendaDay(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Group flat [instances] into forward-looking [AgendaDay]s (only days that
|
* Group flat [instances] into forward-looking [AgendaDay]s (only days that
|
||||||
* actually carry events). An event that began before [anchor] (ongoing or
|
* actually carry events). A multi-day event surfaces on *every* day it spans,
|
||||||
* multi-day) is clamped to the anchor day so it still surfaces on top. Within a
|
* not just its first — clamped to [[anchor], [windowEnd]] so an event that began
|
||||||
* day, all-day events sort first, then ascending by start time, then title.
|
* 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
|
* Shared by the Agenda screen and the agenda home-screen widget so both group
|
||||||
* and order identically.
|
* and order identically.
|
||||||
*/
|
*/
|
||||||
fun groupAgendaDays(
|
fun groupAgendaDays(
|
||||||
anchor: LocalDate,
|
anchor: LocalDate,
|
||||||
|
windowEnd: LocalDate,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
): List<AgendaDay> =
|
): List<AgendaDay> {
|
||||||
instances
|
val byDay = sortedMapOf<LocalDate, MutableList<EventInstance>>()
|
||||||
.groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) }
|
for (instance in instances) {
|
||||||
.toSortedMap()
|
val firstDay = instance.spanFirstDay(zone).coerceAtLeast(anchor)
|
||||||
.map { (date, dayEvents) ->
|
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(
|
AgendaDay(
|
||||||
date = date,
|
date = date,
|
||||||
events = dayEvents.sortedWith(
|
events = dayEvents.sortedWith(
|
||||||
@@ -40,6 +120,7 @@ fun groupAgendaDays(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure [today] surfaces as the first agenda day even when it carries no
|
* Ensure [today] surfaces as the first agenda day even when it carries no
|
||||||
@@ -79,5 +160,12 @@ sealed interface AgendaUiState {
|
|||||||
val rangeEnd: LocalDate,
|
val rangeEnd: LocalDate,
|
||||||
/** Whether to show the top range bar — header + switcher (toggle, on by default). */
|
/** Whether to show the top range bar — header + switcher (toggle, on by default). */
|
||||||
val showRangeBar: Boolean,
|
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
|
) : AgendaUiState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
|||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
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.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
@@ -20,13 +20,11 @@ import kotlinx.coroutines.flow.catch
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import java.util.Locale
|
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
import kotlinx.datetime.atTime
|
import kotlinx.datetime.atTime
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
@@ -51,8 +49,7 @@ class AgendaViewModel @Inject constructor(
|
|||||||
) { range, showBar -> AgendaSettings(range, showBar) }
|
) { range, showBar -> AgendaSettings(range, showBar) }
|
||||||
|
|
||||||
// First day of the week, for the calendar-aligned "this week" range.
|
// First day of the week, for the calendar-aligned "this week" range.
|
||||||
private val weekStartDay = settingsPrefs.weekStart
|
private val weekStartDay = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||||
.map { it.resolveFirstDay(Locale.getDefault()) }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How to treat events that already ended today (show / dim / hide). A display
|
* 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)
|
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
}
|
}
|
||||||
val anchor = params.anchor
|
val anchor = params.anchor
|
||||||
val days = groupAgendaDays(anchor, instances, zone)
|
|
||||||
val rangeEnd = anchor.plus(
|
val rangeEnd = anchor.plus(
|
||||||
params.range.dayCount(anchor, params.weekStart) - 1,
|
params.range.dayCount(anchor, params.weekStart) - 1,
|
||||||
DateTimeUnit.DAY,
|
DateTimeUnit.DAY,
|
||||||
)
|
)
|
||||||
|
val days = groupAgendaDays(anchor, rangeEnd, instances, zone)
|
||||||
return AgendaUiState.Success(
|
return AgendaUiState.Success(
|
||||||
anchor = anchor,
|
anchor = anchor,
|
||||||
today = todayDate,
|
today = todayDate,
|
||||||
@@ -172,6 +169,7 @@ class AgendaViewModel @Inject constructor(
|
|||||||
rangeIsOverride = params.rangeIsOverride,
|
rangeIsOverride = params.rangeIsOverride,
|
||||||
rangeEnd = rangeEnd,
|
rangeEnd = rangeEnd,
|
||||||
showRangeBar = params.showRangeBar,
|
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.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.Cloud
|
||||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -33,6 +32,7 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
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
|
* The app's single "which calendar" selection list, shared by the event editor
|
||||||
@@ -99,13 +99,7 @@ private fun CalendarPickerGroup(
|
|||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
trailing = if (isSelected) {
|
trailing = if (isSelected) {
|
||||||
{
|
{ SelectedCheck() }
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
null
|
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
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.animation.ContentTransform
|
import androidx.compose.animation.ContentTransform
|
||||||
|
import androidx.compose.animation.SizeTransform
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
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*
|
* The M3 Expressive spatial spring used for the month/week/day slide: the
|
||||||
* spring-physics spec from the active motion scheme — snappy with a subtle
|
* *default* spring-physics spec from the active motion scheme, rather than a
|
||||||
* springy settle, rather than a fixed easing curve.
|
* 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
|
* Read it in a composable scope (this helper) so it can be captured by the
|
||||||
* non-composable `AnimatedContent` transitionSpec lambda.
|
* non-composable `AnimatedContent` transitionSpec lambda.
|
||||||
@@ -33,27 +38,34 @@ import androidx.compose.ui.unit.IntOffset
|
|||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
||||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
MaterialTheme.motionScheme.defaultSpatialSpec()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The fast effects spec from the active motion scheme, for opacity (fade)
|
* The effects spec from the active motion scheme, for the opacity half of the
|
||||||
* transitions. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
||||||
* for use in non-composable transition lambdas and as the reduced-motion fallback.
|
* 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)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
|
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
|
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
||||||
* (e.g. "today"); a jump reuses the forward direction.
|
* (e.g. "today"); a jump reuses the forward direction.
|
||||||
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
* @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].
|
* [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(
|
fun calendarSlideTransition(
|
||||||
slideDir: Int,
|
slideDir: Int,
|
||||||
@@ -65,6 +77,26 @@ fun calendarSlideTransition(
|
|||||||
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
||||||
}
|
}
|
||||||
val dir = if (slideDir == 0) 1 else slideDir
|
val dir = if (slideDir == 0) 1 else slideDir
|
||||||
return slideInHorizontally(spec) { w -> dir * w }
|
return ContentTransform(
|
||||||
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
|
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.Palette
|
||||||
import androidx.compose.material.icons.filled.People
|
import androidx.compose.material.icons.filled.People
|
||||||
import androidx.compose.material.icons.filled.Place
|
import androidx.compose.material.icons.filled.Place
|
||||||
|
import androidx.compose.material.icons.filled.Public
|
||||||
import androidx.compose.material.icons.filled.Repeat
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
@@ -24,6 +25,7 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
|
|||||||
fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
|
fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
|
||||||
EventFormField.Location -> R.string.event_detail_location
|
EventFormField.Location -> R.string.event_detail_location
|
||||||
EventFormField.Description -> R.string.event_detail_description
|
EventFormField.Description -> R.string.event_detail_description
|
||||||
|
EventFormField.Timezone -> R.string.event_detail_timezone
|
||||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
EventFormField.Reminders -> R.string.event_detail_reminders
|
||||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||||
EventFormField.Availability -> R.string.event_edit_availability
|
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) {
|
fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) {
|
||||||
EventFormField.Location -> Icons.Default.Place
|
EventFormField.Location -> Icons.Default.Place
|
||||||
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
||||||
|
EventFormField.Timezone -> Icons.Default.Public
|
||||||
EventFormField.Reminders -> Icons.Default.Notifications
|
EventFormField.Reminders -> Icons.Default.Notifications
|
||||||
EventFormField.Recurrence -> Icons.Default.Repeat
|
EventFormField.Recurrence -> Icons.Default.Repeat
|
||||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
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.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.FullScreenPicker
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
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. */
|
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun PickerDescription(text: String) {
|
internal fun PickerDescription(text: String) {
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
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
|
* 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 androidx.compose.ui.text.font.FontStyle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import java.time.DayOfWeek
|
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.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
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. */
|
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
|
||||||
private fun parseUntilDate(raw: String, locale: Locale): String? {
|
private fun parseUntilDate(raw: String, locale: Locale): String? {
|
||||||
val digits = raw.takeWhile { it.isDigit() }
|
val date = untilLocalDate(raw) ?: return null
|
||||||
if (digits.length < 8) return null
|
return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
||||||
return try {
|
}
|
||||||
val date = java.time.LocalDate.of(
|
|
||||||
|
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(0, 4).toInt(),
|
||||||
digits.substring(4, 6).toInt(),
|
digits.substring(4, 6).toInt(),
|
||||||
digits.substring(6, 8).toInt(),
|
digits.substring(6, 8).toInt(),
|
||||||
)
|
)
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
}.getOrNull()
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -43,7 +42,6 @@ import androidx.compose.material3.rememberDrawerState
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -68,8 +66,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
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.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
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.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
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.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
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.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
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.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.LocalDate
|
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 java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -125,6 +128,7 @@ fun DayScreen(
|
|||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
todayInToolbar: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
initialDateIso: String? = null,
|
initialDateIso: String? = null,
|
||||||
viewModel: DayViewModel = hiltViewModel(),
|
viewModel: DayViewModel = hiltViewModel(),
|
||||||
@@ -157,6 +161,13 @@ fun DayScreen(
|
|||||||
else -> true
|
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.
|
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
|
||||||
var slideDir by remember { mutableIntStateOf(0) }
|
var slideDir by remember { mutableIntStateOf(0) }
|
||||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||||
@@ -205,16 +216,19 @@ fun DayScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
DayTopBar(
|
DayTopBar(
|
||||||
date = date,
|
date = date,
|
||||||
|
currentYear = currentYear,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
|
showTodayButton = todayInToolbar,
|
||||||
|
onToday = jumpToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
CalendarFabColumn(
|
CalendarFabColumn(
|
||||||
todayVisible = !isOnToday,
|
todayVisible = !isOnToday && !todayInToolbar,
|
||||||
todayText = stringResource(R.string.day_today_action),
|
todayText = stringResource(R.string.day_today_action),
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
onCreate = { onCreateEvent(date, null) },
|
onCreate = { onCreateEvent(date, null) },
|
||||||
@@ -251,8 +265,6 @@ private fun DayContent(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val threshold = with(density) { 24.dp.toPx() }
|
|
||||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
@@ -281,20 +293,7 @@ private fun DayContent(
|
|||||||
// Whole-page horizontal swipe, one level above the timeline's vertical
|
// Whole-page horizontal swipe, one level above the timeline's vertical
|
||||||
// scroll: a horizontal drag crosses this detector's slop, while a 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.
|
// drag is consumed by the inner scroll first — the two gestures coexist.
|
||||||
val swipeModifier = Modifier.pointerInput(Unit) {
|
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||||
detectHorizontalDragGestures(
|
|
||||||
onDragStart = { dragAccum = 0f },
|
|
||||||
onDragEnd = {
|
|
||||||
when {
|
|
||||||
dragAccum < -threshold -> onSwipeNext()
|
|
||||||
dragAccum > threshold -> onSwipePrev()
|
|
||||||
}
|
|
||||||
dragAccum = 0f
|
|
||||||
},
|
|
||||||
onDragCancel = { dragAccum = 0f },
|
|
||||||
onHorizontalDrag = { _, drag -> dragAccum += drag },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = state,
|
targetState = state,
|
||||||
@@ -360,16 +359,20 @@ private fun DaySuccess(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DayTopBar(
|
private fun DayTopBar(
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
|
currentYear: Int,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
|
showTodayButton: Boolean,
|
||||||
|
onToday: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
|
val locale = currentLocale()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
text = formatDayTitle(date),
|
text = formatDayTitle(date, locale, currentYear),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -382,6 +385,7 @@ private fun DayTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
TodayAction(show = showTodayButton, onToday = onToday)
|
||||||
IconButton(onClick = onOpenSearch) {
|
IconButton(onClick = onOpenSearch) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Search,
|
imageVector = Icons.Default.Search,
|
||||||
@@ -669,10 +673,10 @@ private fun DayLoading() {
|
|||||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
||||||
formatMinuteOfDay(min, is24Hour, locale)
|
formatMinuteOfDay(min, is24Hour, locale)
|
||||||
|
|
||||||
private fun formatDayTitle(date: LocalDate): String {
|
private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String =
|
||||||
val locale = Locale.getDefault()
|
formatCalendarTitle(
|
||||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
|
||||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
locale = locale,
|
||||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
currentYear = currentYear,
|
||||||
return "$weekday, ${date.day}. $monthName ${date.year}"
|
skeleton = "EEEdMMM",
|
||||||
}
|
)
|
||||||
|
|||||||
@@ -93,22 +93,26 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
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.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
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.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.floret.components.OptionCard
|
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.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlin.time.toJavaInstant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import kotlin.time.Instant
|
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
|
// Time zone — only when the event is timed and pinned to a zone other
|
||||||
// than the device's, so cross-zone events read unambiguously.
|
// than the device's, so cross-zone events read unambiguously. It answers
|
||||||
foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel ->
|
// "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))
|
Spacer(Modifier.height(gap))
|
||||||
DetailCard(
|
DetailCard(
|
||||||
icon = Icons.Default.Public,
|
icon = Icons.Default.Public,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_timezone),
|
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)
|
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
|
* [tz] resolved as a [TimeZoneOption], but only when the event is timed and
|
||||||
* but only when the event is timed and pinned to a zone different from the
|
* pinned to a zone different from the device's — the cases where showing it
|
||||||
* device's. Returns null when there's nothing worth showing.
|
* 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
|
if (isAllDay || tz.isNullOrBlank()) return null
|
||||||
val deviceZone = ZoneId.systemDefault().id
|
if (tz == ZoneId.systemDefault().id) return null
|
||||||
if (tz == deviceZone) return null
|
return timeZoneOptionOf(
|
||||||
return try {
|
tz,
|
||||||
val zone = ZoneId.of(tz)
|
locale,
|
||||||
val name = zone.getDisplayName(JavaTextStyle.FULL, locale)
|
at = at.toJavaInstant(),
|
||||||
if (name == tz) tz else "$name ($tz)"
|
regionOf = ::icuTimeZoneRegion,
|
||||||
} catch (e: Exception) {
|
)
|
||||||
tz
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */
|
/** 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.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
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.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
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.EventFormProblem
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
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.CalendarPickerGroups
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
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.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
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.calendula.ui.common.eventFormFieldLabel
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.OptionCard
|
import de.jeanlucmakiola.floret.components.OptionCard
|
||||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
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.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
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.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
@@ -157,13 +164,14 @@ import kotlinx.datetime.TimeZone
|
|||||||
import kotlinx.datetime.isoDayNumber
|
import kotlinx.datetime.isoDayNumber
|
||||||
import kotlinx.datetime.toJavaDayOfWeek
|
import kotlinx.datetime.toJavaDayOfWeek
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toKotlinDayOfWeek
|
import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toJavaLocalTime
|
import kotlinx.datetime.toJavaLocalTime
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.toJavaInstant
|
||||||
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.time.temporal.WeekFields
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
|
||||||
@@ -495,6 +503,7 @@ private fun EventEditContent(
|
|||||||
) {
|
) {
|
||||||
val form = state.form
|
val form = state.form
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
|
val firstDayOfWeek by viewModel.firstDayOfWeek.collectAsStateWithLifecycle()
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
// The title, date and recurrence are managed by the special-dates sync, so
|
// The title, date and recurrence are managed by the special-dates sync, so
|
||||||
// they're locked here; everything else (reminders, location, notes) is the
|
// 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 showRecurrencePicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
|
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showColorPicker by rememberSaveable { mutableStateOf(false) }
|
var showColorPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showTimezonePicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
// Pick a guest from contacts: a one-shot system picker returning the chosen
|
// Pick a guest from contacts: a one-shot system picker returning the chosen
|
||||||
@@ -670,6 +680,28 @@ private fun EventEditContent(
|
|||||||
color = MaterialTheme.colorScheme.error,
|
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))
|
Spacer(Modifier.height(gap))
|
||||||
@@ -706,6 +738,54 @@ private fun EventEditContent(
|
|||||||
|
|
||||||
// Optional sections: which ones render by default is a setting; the
|
// Optional sections: which ones render by default is a setting; the
|
||||||
// rest unfold behind the "more fields" button below.
|
// 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) {
|
OptionalFormSection(visible = EventFormField.Location in state.visibleFields) {
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
EditCard(
|
EditCard(
|
||||||
@@ -1062,6 +1142,7 @@ private fun EventEditContent(
|
|||||||
RecurrencePickerDialog(
|
RecurrencePickerDialog(
|
||||||
current = form.rrule,
|
current = form.rrule,
|
||||||
startDay = form.start.date.dayOfWeek,
|
startDay = form.start.date.dayOfWeek,
|
||||||
|
firstDayOfWeek = firstDayOfWeek,
|
||||||
onSelect = { rrule ->
|
onSelect = { rrule ->
|
||||||
viewModel.setRecurrence(rrule)
|
viewModel.setRecurrence(rrule)
|
||||||
showRecurrencePicker = false
|
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) {
|
if (showColorPicker) {
|
||||||
ColorPickerDialog(
|
ColorPickerDialog(
|
||||||
palette = state.colorPalette,
|
palette = state.colorPalette,
|
||||||
@@ -1223,6 +1314,7 @@ private enum class RecurrenceEndMode { Never, Until, Count }
|
|||||||
private fun RecurrencePickerDialog(
|
private fun RecurrencePickerDialog(
|
||||||
current: String?,
|
current: String?,
|
||||||
startDay: DayOfWeek,
|
startDay: DayOfWeek,
|
||||||
|
firstDayOfWeek: DayOfWeek,
|
||||||
onSelect: (String?) -> Unit,
|
onSelect: (String?) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -1257,8 +1349,21 @@ private fun RecurrencePickerDialog(
|
|||||||
|
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val untilDate = untilIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
val untilDate = untilIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
||||||
val interval = intervalText.toIntOrNull()?.takeIf { it in 1..999 }
|
// remember() must not sit behind the ?. — a slot that appears and disappears
|
||||||
val count = countText.toIntOrNull()?.takeIf { it in 1..999 }
|
// 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) {
|
val customEnd: RecurrenceEnd? = when (endMode) {
|
||||||
RecurrenceEndMode.Never -> RecurrenceEnd.Never
|
RecurrenceEndMode.Never -> RecurrenceEnd.Never
|
||||||
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
|
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
|
||||||
@@ -1294,6 +1399,11 @@ private fun RecurrencePickerDialog(
|
|||||||
title = stringResource(R.string.event_edit_recurrence_none),
|
title = stringResource(R.string.event_edit_recurrence_none),
|
||||||
position = positionOf(0, rowCount),
|
position = positionOf(0, rowCount),
|
||||||
selected = current == null,
|
selected = current == null,
|
||||||
|
trailing = if (current == null) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onClick = { onSelect(null) },
|
onClick = { onSelect(null) },
|
||||||
)
|
)
|
||||||
RecurrenceFreq.entries.forEachIndexed { index, entry ->
|
RecurrenceFreq.entries.forEachIndexed { index, entry ->
|
||||||
@@ -1301,6 +1411,11 @@ private fun RecurrencePickerDialog(
|
|||||||
title = stringResource(recurrencePresetLabel(entry)),
|
title = stringResource(recurrencePresetLabel(entry)),
|
||||||
position = positionOf(index + 1, rowCount),
|
position = positionOf(index + 1, rowCount),
|
||||||
selected = isPlainPreset && parsed?.freq == entry,
|
selected = isPlainPreset && parsed?.freq == entry,
|
||||||
|
trailing = if (isPlainPreset && parsed?.freq == entry) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
|
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1308,12 +1423,54 @@ private fun RecurrencePickerDialog(
|
|||||||
title = stringResource(R.string.event_edit_recurrence_custom),
|
title = stringResource(R.string.event_edit_recurrence_custom),
|
||||||
position = positionOf(rowCount - 1, rowCount),
|
position = positionOf(rowCount - 1, rowCount),
|
||||||
selected = current != null && !isPlainPreset,
|
selected = current != null && !isPlainPreset,
|
||||||
|
trailing = if (current != null && !isPlainPreset) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onClick = { customMode = true },
|
onClick = { customMode = true },
|
||||||
)
|
)
|
||||||
} else {
|
} 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(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
modifier = Modifier.padding(horizontal = 24.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
@@ -1326,44 +1483,86 @@ private fun RecurrencePickerDialog(
|
|||||||
onValueChange = { intervalText = it },
|
onValueChange = { intervalText = it },
|
||||||
placeholder = "1",
|
placeholder = "1",
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(12.dp))
|
}
|
||||||
DialogUnitDropdown(
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
label = stringResource(recurrenceUnitLabel(freq)),
|
RecurrenceFreq.entries.forEachIndexed { index, entry ->
|
||||||
entries = RecurrenceFreq.entries.map {
|
SegmentedButton(
|
||||||
stringResource(recurrenceUnitLabel(it))
|
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(
|
WeekdayToggleRow(
|
||||||
selected = daysMask.toDaySet(),
|
selected = daysMask.toDaySet(),
|
||||||
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
|
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
|
||||||
locale = locale,
|
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(
|
||||||
text = stringResource(R.string.event_edit_recurrence_ends),
|
text = stringResource(R.string.event_edit_recurrence_ends),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.event_edit_recurrence_end_never),
|
title = stringResource(R.string.event_edit_recurrence_end_never),
|
||||||
position = positionOf(0, 3),
|
position = Position.Top,
|
||||||
selected = endMode == RecurrenceEndMode.Never,
|
selected = endMode == RecurrenceEndMode.Never,
|
||||||
|
trailing = if (endMode == RecurrenceEndMode.Never) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onClick = { endMode = RecurrenceEndMode.Never },
|
onClick = { endMode = RecurrenceEndMode.Never },
|
||||||
)
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.event_edit_recurrence_end_until),
|
title = stringResource(R.string.event_edit_recurrence_end_until),
|
||||||
summary = untilDate?.let {
|
summary = untilSummary,
|
||||||
remember(it, locale) {
|
position = Position.Middle,
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
|
||||||
.withLocale(locale).format(it.toJavaLocalDate())
|
|
||||||
}
|
|
||||||
},
|
|
||||||
position = positionOf(1, 3),
|
|
||||||
selected = endMode == RecurrenceEndMode.Until,
|
selected = endMode == RecurrenceEndMode.Until,
|
||||||
|
trailing = if (endMode == RecurrenceEndMode.Until) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
endMode = RecurrenceEndMode.Until
|
endMode = RecurrenceEndMode.Until
|
||||||
showUntilPicker = true
|
showUntilPicker = true
|
||||||
@@ -1371,14 +1570,34 @@ private fun RecurrencePickerDialog(
|
|||||||
)
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.event_edit_recurrence_end_count),
|
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,
|
selected = endMode == RecurrenceEndMode.Count,
|
||||||
|
trailing = if (endMode == RecurrenceEndMode.Count) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onClick = { endMode = RecurrenceEndMode.Count },
|
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(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
) {
|
) {
|
||||||
DialogAmountField(
|
DialogAmountField(
|
||||||
value = countText,
|
value = countText,
|
||||||
@@ -1394,6 +1613,9 @@ private fun RecurrencePickerDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showUntilPicker) {
|
if (showUntilPicker) {
|
||||||
CalendarDatePickerDialog(
|
CalendarDatePickerDialog(
|
||||||
@@ -1404,7 +1626,13 @@ private fun RecurrencePickerDialog(
|
|||||||
untilIso = it.toString()
|
untilIso = it.toString()
|
||||||
showUntilPicker = false
|
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>,
|
selected: Set<DayOfWeek>,
|
||||||
onToggle: (DayOfWeek) -> Unit,
|
onToggle: (DayOfWeek) -> Unit,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
firstDay: DayOfWeek,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val days = remember(locale) {
|
val days = remember(firstDay) {
|
||||||
val first = WeekFields.of(locale).firstDayOfWeek.toKotlinDayOfWeek()
|
(0 until 7).map { DayOfWeek(((firstDay.isoDayNumber - 1 + it) % 7) + 1) }
|
||||||
(0 until 7).map { DayOfWeek(((first.isoDayNumber - 1 + it) % 7) + 1) }
|
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
days.forEach { day ->
|
days.forEach { day ->
|
||||||
val isSelected = day in selected
|
val isSelected = day in selected
|
||||||
@@ -1479,18 +1708,14 @@ private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) {
|
|||||||
RecurrenceFreq.Yearly -> R.string.recurrence_unit_years
|
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 =
|
* Corner shape for a card at [position] in a grouped run. Delegates to the
|
||||||
when (position) {
|
* family primitive with the same radii [GroupedSurface] draws at rest — a local
|
||||||
Position.Alone -> RoundedCornerShape(full)
|
* copy had drifted to 20dp, so a card sharing a run with GroupedRows had
|
||||||
Position.Top -> RoundedCornerShape(
|
* visibly tighter outer corners than the rows above it.
|
||||||
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
|
*/
|
||||||
)
|
private fun groupedShape(position: Position): Shape =
|
||||||
Position.Middle -> RoundedCornerShape(small)
|
floretGroupedShape(position, full = 22.dp, small = 6.dp)
|
||||||
Position.Bottom -> RoundedCornerShape(
|
|
||||||
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The 2dp gap that visually separates grouped cards (none after the last). */
|
/** The 2dp gap that visually separates grouped cards (none after the last). */
|
||||||
private fun groupedGap(position: Position): Modifier = when (position) {
|
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).
|
* 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
|
* Thin wrapper over the shared [InlineTextField] so the form and the rest of
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ data class EventEditUiState(
|
|||||||
* neither list.
|
* neither list.
|
||||||
*/
|
*/
|
||||||
val hiddenFields: List<EventFormField> = emptyList(),
|
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). */
|
/** True while editing an existing event (the calendar is then fixed). */
|
||||||
val isEditing: Boolean = false,
|
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.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
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.data.prefs.resolveDefaultReminder
|
||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
@@ -39,6 +40,8 @@ import kotlinx.coroutines.flow.flowOn
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
@@ -219,7 +222,8 @@ class EventEditViewModel @Inject constructor(
|
|||||||
).flowOn(io),
|
).flowOn(io),
|
||||||
colorPalette,
|
colorPalette,
|
||||||
allCalendars,
|
allCalendars,
|
||||||
) { local, external, palette, allCalendars ->
|
settingsPrefs.recentTimeZones.flowOn(io),
|
||||||
|
) { local, external, palette, allCalendars, recentTimeZones ->
|
||||||
val form = local.form ?: return@combine null
|
val form = local.form ?: return@combine null
|
||||||
val resolvedId = form.calendarId
|
val resolvedId = form.calendarId
|
||||||
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
||||||
@@ -235,14 +239,19 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val pickerCalendars =
|
val pickerCalendars =
|
||||||
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
|
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
|
||||||
else external.writable
|
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(
|
EventEditUiState(
|
||||||
form = resolved,
|
form = resolved,
|
||||||
calendars = pickerCalendars,
|
calendars = pickerCalendars,
|
||||||
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
||||||
saveState = local.saveState,
|
saveState = local.saveState,
|
||||||
visibleFields = visibleFields,
|
visibleFields = visibleFields,
|
||||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
hiddenFields = (offerableFields - visibleFields).sorted(),
|
||||||
|
recentTimeZones = recentTimeZones,
|
||||||
isEditing = local.editTarget != null,
|
isEditing = local.editTarget != null,
|
||||||
isManaged = isManaged,
|
isManaged = isManaged,
|
||||||
autofocusTitle = external.autofocusTitle,
|
autofocusTitle = external.autofocusTitle,
|
||||||
@@ -260,6 +269,13 @@ class EventEditViewModel @Inject constructor(
|
|||||||
initialValue = null,
|
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
|
* 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
|
* 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 setLocation(value: String) = update { it.copy(location = value) }
|
||||||
fun setDescription(value: String) = update { it.copy(description = value) }
|
fun setDescription(value: String) = update { it.copy(description = value) }
|
||||||
fun setAllDay(value: Boolean) {
|
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
|
// The default reminder differs for all-day vs timed; re-apply the
|
||||||
// type-appropriate default unless the user has hand-edited it (guarded).
|
// type-appropriate default unless the user has hand-edited it (guarded).
|
||||||
applyDefaultReminder()
|
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
|
* Switching calendars drops any chosen colour: a palette key is
|
||||||
* account-scoped, and a raw colour may be invalid on the new calendar.
|
* 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.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,12 +39,85 @@ data class MonthWeek(
|
|||||||
val countByDay: Map<LocalDate, Int>,
|
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 {
|
sealed interface MonthUiState {
|
||||||
data object Loading : MonthUiState
|
data object Loading : MonthUiState
|
||||||
data class Failure(val reason: FailureReason) : 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(
|
data class Success(
|
||||||
val month: YearMonth,
|
val month: YearMonth,
|
||||||
val today: LocalDate,
|
val today: LocalDate,
|
||||||
val weeks: List<MonthWeek>,
|
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
|
) : MonthUiState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
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.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
@@ -20,21 +20,23 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.Month
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
import kotlinx.datetime.atTime
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.daysUntil
|
||||||
import kotlinx.datetime.minus
|
import kotlinx.datetime.minus
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import java.util.Locale
|
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -48,16 +50,9 @@ class MonthViewModel @Inject constructor(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val zone = TimeZone.currentSystemDefault()
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
private val locale: Locale = Locale.getDefault()
|
|
||||||
|
|
||||||
/** First day of the week, from the Settings preference (AUTO → locale). */
|
/** First day of the week, from the Settings preference (AUTO → locale). */
|
||||||
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.weekStart
|
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||||
.map { it.resolveFirstDay(locale) }
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = DayOfWeek.MONDAY,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Whether to fade events that have already finished (display concern only). */
|
/** Whether to fade events that have already finished (display concern only). */
|
||||||
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||||
@@ -75,6 +70,14 @@ class MonthViewModel @Inject constructor(
|
|||||||
initialValue = false,
|
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
|
private val todayDate: LocalDate
|
||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
@@ -82,8 +85,12 @@ class MonthViewModel @Inject constructor(
|
|||||||
val month: StateFlow<YearMonth> = _month
|
val month: StateFlow<YearMonth> = _month
|
||||||
|
|
||||||
val state: StateFlow<MonthUiState> =
|
val state: StateFlow<MonthUiState> =
|
||||||
combine(_month, weekStart) { ym, ws -> ym to ws }
|
combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(ym, ws, style) }
|
||||||
.flatMapLatest { (ym, ws) ->
|
.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)
|
val range = monthGridRange(ym, ws, zone)
|
||||||
combine(
|
combine(
|
||||||
repository.calendars(),
|
repository.calendars(),
|
||||||
@@ -100,21 +107,202 @@ class MonthViewModel @Inject constructor(
|
|||||||
initialValue = MonthUiState.Loading,
|
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() {
|
fun goToPrev() {
|
||||||
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
||||||
|
realignSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToNext() {
|
fun goToNext() {
|
||||||
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
|
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
|
||||||
|
realignSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToToday() {
|
fun goToToday() {
|
||||||
_month.value = YearMonth(todayDate.year, todayDate.month)
|
_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). */
|
/** Jump to the month containing [date] (drawer jump-to-date). */
|
||||||
fun goToDate(date: LocalDate) {
|
fun goToDate(date: LocalDate) {
|
||||||
_month.value = YearMonth(date.year, date.month)
|
_month.value = YearMonth(date.year, date.month)
|
||||||
|
_selectedDate.value = date
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildState(
|
private fun buildState(
|
||||||
@@ -126,10 +314,13 @@ class MonthViewModel @Inject constructor(
|
|||||||
if (calendars.isEmpty()) {
|
if (calendars.isEmpty()) {
|
||||||
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
}
|
}
|
||||||
|
val weeks = layoutMonthWeeks(ym, weekStart, instances, zone)
|
||||||
return MonthUiState.Success(
|
return MonthUiState.Success(
|
||||||
month = ym,
|
month = ym,
|
||||||
today = todayDate,
|
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,
|
weekStart: DayOfWeek,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
zone: TimeZone,
|
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> {
|
): List<MonthWeek> {
|
||||||
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
|
val gridStart = firstOfMonth(ym).startOfGridWeek(weekStart)
|
||||||
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
|
val weekCount = weekRowsInMonth(ym, 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
|
|
||||||
|
|
||||||
return (0 until weekCount).map { row ->
|
return (0 until weekCount).map { row ->
|
||||||
val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
|
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 weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
|
||||||
val (bars, singles) = weekEvents.partition { ev ->
|
val (bars, singles) = weekEvents.partition { ev ->
|
||||||
ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
|
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),
|
continuesRight = s.event.coversDay(days.last().plus(1, DateTimeUnit.DAY), zone),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MonthWeek(
|
return MonthWeek(
|
||||||
days = days,
|
days = days,
|
||||||
spans = spans,
|
spans = spans,
|
||||||
timedByDay = days.associateWith { d ->
|
timedByDay = days.associateWith { d ->
|
||||||
@@ -180,9 +438,25 @@ internal fun layoutMonthWeeks(
|
|||||||
},
|
},
|
||||||
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The on-screen grid spans 6 weeks anchored on [weekStart]. Includes the
|
* The on-screen grid spans 6 weeks anchored on [weekStart]. Includes the
|
||||||
* trailing days of the previous month and the leading days of the next month.
|
* trailing days of the previous month and the leading days of the next month.
|
||||||
@@ -200,6 +474,198 @@ internal fun monthGridRange(
|
|||||||
return start..end
|
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 {
|
internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate {
|
||||||
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
||||||
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
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.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
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.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
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.animation.slideOutHorizontally
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.BugReport
|
||||||
import androidx.compose.material.icons.filled.Cake
|
import androidx.compose.material.icons.filled.Cake
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
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.Check
|
||||||
|
import androidx.compose.material.icons.filled.Dashboard
|
||||||
import androidx.compose.material.icons.filled.DragHandle
|
import androidx.compose.material.icons.filled.DragHandle
|
||||||
import androidx.compose.material.icons.filled.SwapVert
|
import androidx.compose.material.icons.filled.SwapVert
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
@@ -93,6 +96,7 @@ import androidx.lifecycle.LifecycleEventObserver
|
|||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.appname.LauncherName
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
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.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
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.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
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.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
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.ReorderableColumn
|
||||||
import de.jeanlucmakiola.floret.components.ReorderableRowHeight
|
import de.jeanlucmakiola.floret.components.ReorderableRowHeight
|
||||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
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.REMINDER_PRESETS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
|
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
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.eventFormFieldIcon
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.BundledFont
|
import de.jeanlucmakiola.calendula.ui.theme.BundledFont
|
||||||
@@ -487,8 +494,10 @@ private fun AppearanceScreen(
|
|||||||
var showPastEvents by remember { mutableStateOf(false) }
|
var showPastEvents by remember { mutableStateOf(false) }
|
||||||
var showBrandFont by remember { mutableStateOf(false) }
|
var showBrandFont by remember { mutableStateOf(false) }
|
||||||
var showPlainFont by remember { mutableStateOf(false) }
|
var showPlainFont by remember { mutableStateOf(false) }
|
||||||
|
var showAppName by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val fonts by viewModel.fontState.collectAsStateWithLifecycle()
|
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.
|
// A picked file that didn't parse as a font: tell the user and keep the old choice.
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val importFailedMessage = stringResource(R.string.settings_font_import_failed)
|
val importFailedMessage = stringResource(R.string.settings_font_import_failed)
|
||||||
@@ -571,6 +580,18 @@ private fun AppearanceScreen(
|
|||||||
position = Position.Top,
|
position = Position.Top,
|
||||||
onClick = { showDefaultView = true },
|
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(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_week_start),
|
title = stringResource(R.string.settings_week_start),
|
||||||
summary = weekStartLabel(state.weekStart),
|
summary = weekStartLabel(state.weekStart),
|
||||||
@@ -667,8 +688,58 @@ private fun AppearanceScreen(
|
|||||||
},
|
},
|
||||||
onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) },
|
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) {
|
if (showTheme) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_theme),
|
title = stringResource(R.string.settings_theme),
|
||||||
@@ -778,12 +849,24 @@ private fun ViewsScreen(
|
|||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
var showMonthStyle by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_views),
|
title = stringResource(R.string.settings_section_views),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
) {
|
) {
|
||||||
val config = state.quickSwitchConfig
|
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))
|
SectionHeader(stringResource(R.string.settings_quick_switch_header))
|
||||||
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
||||||
Spacer(Modifier.height(8.dp))
|
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]
|
/** One reorderable view row: the view's icon and name, an optional [trailing]
|
||||||
@@ -1081,13 +1174,7 @@ private fun NotificationsScreen(
|
|||||||
},
|
},
|
||||||
position = Position.Top,
|
position = Position.Top,
|
||||||
trailing = if (batteryExempt) {
|
trailing = if (batteryExempt) {
|
||||||
{
|
{ SelectedCheck() }
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
@@ -1692,6 +1779,98 @@ private fun openUrl(context: Context, url: String) {
|
|||||||
runCatching { context.startActivity(intent) }
|
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
|
@Composable
|
||||||
private fun themeLabel(mode: ThemeMode): String = stringResource(
|
private fun themeLabel(mode: ThemeMode): String = stringResource(
|
||||||
when (mode) {
|
when (mode) {
|
||||||
@@ -1833,13 +2012,7 @@ private fun FontOptionRow(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
trailing = if (selected) {
|
trailing = if (selected) {
|
||||||
{
|
{ SelectedCheck() }
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
null
|
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.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
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
|
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||||
@@ -37,6 +38,8 @@ data class SettingsUiState(
|
|||||||
val dimCompletedEvents: Boolean = false,
|
val dimCompletedEvents: Boolean = false,
|
||||||
/** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */
|
/** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */
|
||||||
val showWeekNumbers: Boolean = false,
|
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). */
|
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||||
/** How far ahead the agenda widget shows events (v2.11). */
|
/** How far ahead the agenda widget shows events (v2.11). */
|
||||||
@@ -49,6 +52,8 @@ data class SettingsUiState(
|
|||||||
val defaultView: CalendarView = CalendarView.Week,
|
val defaultView: CalendarView = CalendarView.Week,
|
||||||
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
|
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
|
||||||
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
|
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. */
|
/** Order of the views in the navigation drawer (#24); every view is always listed. */
|
||||||
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
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.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
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.agenda.storageValue
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
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.AppFontSettings
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
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.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
@@ -62,6 +67,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
repository: CalendarRepository,
|
repository: CalendarRepository,
|
||||||
private val specialDatesEngine: SpecialDatesSyncEngine,
|
private val specialDatesEngine: SpecialDatesSyncEngine,
|
||||||
specialDatesSpec: SpecialDatesCalendarSpec,
|
specialDatesSpec: SpecialDatesCalendarSpec,
|
||||||
|
private val launcherNameManager: LauncherNameManager,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
@ApplicationContext private val appContext: Context,
|
@ApplicationContext private val appContext: Context,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@@ -124,8 +130,9 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.showWeekNumbers,
|
prefs.showWeekNumbers,
|
||||||
prefs.agendaShowToday,
|
prefs.agendaShowToday,
|
||||||
prefs.softenCalendarColors,
|
prefs.softenCalendarColors,
|
||||||
) { hourLines, weekNumbers, showToday, soften ->
|
prefs.todayButtonInToolbar,
|
||||||
DisplayToggles(hourLines, weekNumbers, showToday, soften)
|
) { hourLines, weekNumbers, showToday, soften, todayInToolbar ->
|
||||||
|
DisplayToggles(hourLines, weekNumbers, showToday, soften, todayInToolbar)
|
||||||
},
|
},
|
||||||
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
||||||
ViewSettings(
|
ViewSettings(
|
||||||
@@ -134,6 +141,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
showWeekNumbers = toggles.showWeekNumbers,
|
showWeekNumbers = toggles.showWeekNumbers,
|
||||||
agendaShowToday = toggles.agendaShowToday,
|
agendaShowToday = toggles.agendaShowToday,
|
||||||
softenColors = toggles.softenColors,
|
softenColors = toggles.softenColors,
|
||||||
|
todayButtonInToolbar = toggles.todayButtonInToolbar,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
combine(
|
combine(
|
||||||
@@ -143,8 +151,12 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.dimCompletedEvents,
|
prefs.dimCompletedEvents,
|
||||||
// View customisation (#24) folded into one flow so it fits this
|
// View customisation (#24) folded into one flow so it fits this
|
||||||
// group — the outer combine is already at its five-arg limit.
|
// group — the outer combine is already at its five-arg limit.
|
||||||
combine(prefs.quickSwitchConfig, prefs.drawerViewOrder) { quickSwitch, drawer ->
|
combine(
|
||||||
ViewCustomization(quickSwitch, drawer)
|
prefs.quickSwitchConfig,
|
||||||
|
prefs.drawerViewOrder,
|
||||||
|
prefs.monthViewStyle,
|
||||||
|
) { quickSwitch, drawer, monthStyle ->
|
||||||
|
ViewCustomization(quickSwitch, drawer, monthStyle)
|
||||||
},
|
},
|
||||||
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
||||||
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
||||||
@@ -159,12 +171,14 @@ class SettingsViewModel @Inject constructor(
|
|||||||
showWeekNumbers = views.showWeekNumbers,
|
showWeekNumbers = views.showWeekNumbers,
|
||||||
agendaShowToday = views.agendaShowToday,
|
agendaShowToday = views.agendaShowToday,
|
||||||
softenColors = views.softenColors,
|
softenColors = views.softenColors,
|
||||||
|
todayButtonInToolbar = views.todayButtonInToolbar,
|
||||||
agendaShowRangeBar = misc.showRangeBar,
|
agendaShowRangeBar = misc.showRangeBar,
|
||||||
autofocusEventTitle = misc.autofocusEventTitle,
|
autofocusEventTitle = misc.autofocusEventTitle,
|
||||||
pastEventDisplay = misc.pastEventDisplay,
|
pastEventDisplay = misc.pastEventDisplay,
|
||||||
dimCompletedEvents = misc.dimCompletedEvents,
|
dimCompletedEvents = misc.dimCompletedEvents,
|
||||||
quickSwitchConfig = misc.viewCustomization.quickSwitch,
|
quickSwitchConfig = misc.viewCustomization.quickSwitch,
|
||||||
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
||||||
|
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
||||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
@@ -199,6 +213,28 @@ class SettingsViewModel @Inject constructor(
|
|||||||
initialValue = AppFontSettings(),
|
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
|
// Emitted when a picked font file couldn't be read as a font; the screen
|
||||||
// surfaces it and the previous selection stays put.
|
// surfaces it and the previous selection stays put.
|
||||||
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||||
@@ -234,6 +270,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val showWeekNumbers: Boolean,
|
val showWeekNumbers: Boolean,
|
||||||
val agendaShowToday: Boolean,
|
val agendaShowToday: Boolean,
|
||||||
val softenColors: Boolean,
|
val softenColors: Boolean,
|
||||||
|
val todayButtonInToolbar: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class DisplayToggles(
|
private data class DisplayToggles(
|
||||||
@@ -241,6 +278,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val showWeekNumbers: Boolean,
|
val showWeekNumbers: Boolean,
|
||||||
val agendaShowToday: Boolean,
|
val agendaShowToday: Boolean,
|
||||||
val softenColors: Boolean,
|
val softenColors: Boolean,
|
||||||
|
val todayButtonInToolbar: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class MiscSettings(
|
private data class MiscSettings(
|
||||||
@@ -254,6 +292,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
private data class ViewCustomization(
|
private data class ViewCustomization(
|
||||||
val quickSwitch: QuickSwitchConfig,
|
val quickSwitch: QuickSwitchConfig,
|
||||||
val drawerOrder: List<CalendarView>,
|
val drawerOrder: List<CalendarView>,
|
||||||
|
val monthViewStyle: MonthViewStyle,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
/** 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) }
|
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) {
|
fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.setPastEventDisplay(mode)
|
prefs.setPastEventDisplay(mode)
|
||||||
@@ -510,6 +569,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.updateQuickSwitch { it.copy(order = order) } }
|
viewModelScope.launch { prefs.updateQuickSwitch { it.copy(order = order) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setMonthViewStyle(style: MonthViewStyle) {
|
||||||
|
viewModelScope.launch { prefs.setMonthViewStyle(style) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setDrawerViewOrder(order: List<CalendarView>) {
|
fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||||
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import androidx.compose.animation.animateColorAsState
|
|||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -49,7 +48,6 @@ import androidx.compose.runtime.CompositionLocalProvider
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -77,8 +75,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
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.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
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.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
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.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
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.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||||
@@ -143,6 +144,7 @@ fun WeekScreen(
|
|||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
todayInToolbar: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: WeekViewModel = hiltViewModel(),
|
viewModel: WeekViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -180,6 +182,13 @@ fun WeekScreen(
|
|||||||
else -> true
|
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.
|
// Slide direction for the week transition: +1 = next, -1 = prev, 0 = jump.
|
||||||
var slideDir by remember { mutableIntStateOf(0) }
|
var slideDir by remember { mutableIntStateOf(0) }
|
||||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||||
@@ -228,16 +237,19 @@ fun WeekScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
WeekTopBar(
|
WeekTopBar(
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
|
currentYear = currentYear,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
|
showTodayButton = todayInToolbar,
|
||||||
|
onToday = jumpToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
CalendarFabColumn(
|
CalendarFabColumn(
|
||||||
todayVisible = !isOnCurrentWeek,
|
todayVisible = !isOnCurrentWeek && !todayInToolbar,
|
||||||
todayText = stringResource(R.string.week_today_action),
|
todayText = stringResource(R.string.week_today_action),
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
onCreate = {
|
onCreate = {
|
||||||
@@ -283,8 +295,6 @@ private fun WeekContent(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val threshold = with(density) { 24.dp.toPx() }
|
|
||||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
@@ -316,20 +326,7 @@ private fun WeekContent(
|
|||||||
// vertical scroll: a horizontal drag only crosses *this* detector's slop,
|
// 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
|
// while a vertical drag is consumed by the inner scroll first — so the two
|
||||||
// gestures coexist without fighting.
|
// gestures coexist without fighting.
|
||||||
val swipeModifier = Modifier.pointerInput(Unit) {
|
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||||
detectHorizontalDragGestures(
|
|
||||||
onDragStart = { dragAccum = 0f },
|
|
||||||
onDragEnd = {
|
|
||||||
when {
|
|
||||||
dragAccum < -threshold -> onSwipeNext()
|
|
||||||
dragAccum > threshold -> onSwipePrev()
|
|
||||||
}
|
|
||||||
dragAccum = 0f
|
|
||||||
},
|
|
||||||
onDragCancel = { dragAccum = 0f },
|
|
||||||
onHorizontalDrag = { _, drag -> dragAccum += drag },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = state,
|
targetState = state,
|
||||||
@@ -395,16 +392,20 @@ private fun WeekSuccess(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun WeekTopBar(
|
private fun WeekTopBar(
|
||||||
weekStart: LocalDate,
|
weekStart: LocalDate,
|
||||||
|
currentYear: Int,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
|
showTodayButton: Boolean,
|
||||||
|
onToday: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
|
val locale = currentLocale()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
text = formatWeekRange(weekStart),
|
text = formatWeekTitle(weekStart, locale, currentYear),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -417,6 +418,7 @@ private fun WeekTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
TodayAction(show = showTodayButton, onToday = onToday)
|
||||||
IconButton(onClick = onOpenSearch) {
|
IconButton(onClick = onOpenSearch) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Search,
|
imageVector = Icons.Default.Search,
|
||||||
@@ -854,18 +856,29 @@ private fun WeekLoading() {
|
|||||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String =
|
private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String =
|
||||||
formatMinuteOfDay(min, is24Hour, locale)
|
formatMinuteOfDay(min, is24Hour, locale)
|
||||||
|
|
||||||
private fun formatWeekRange(weekStart: LocalDate): String {
|
/**
|
||||||
val locale = Locale.getDefault()
|
* The week's title: just the month [weekStart] falls in.
|
||||||
val end = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
*
|
||||||
val monthName = { d: LocalDate ->
|
* The day numbers are already in the column headers directly below, so spelling
|
||||||
java.time.Month.of(d.month.ordinal + 1).getDisplayName(JavaTextStyle.SHORT, locale)
|
* 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
|
||||||
return if (weekStart.month == end.month && weekStart.year == end.year) {
|
* month until it is fully gone — a week is seven contiguous days, so the earlier
|
||||||
"${weekStart.day}.–${end.day}. ${monthName(weekStart)} ${weekStart.year}"
|
* month has a day in it exactly while [weekStart] is still inside it. That also
|
||||||
} else if (weekStart.year == end.year) {
|
* makes the *month* depend on nothing but [weekStart], so it cannot drift with
|
||||||
"${weekStart.day}. ${monthName(weekStart)} – ${end.day}. ${monthName(end)} ${end.year}"
|
* the direction you paged in from.
|
||||||
} else {
|
*
|
||||||
"${weekStart.day}. ${monthName(weekStart)} ${weekStart.year} – " +
|
* The *year* is decided from the whole week, not just its start: a week running
|
||||||
"${end.day}. ${monthName(end)} ${end.year}"
|
* 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
|
// the composition from Glance state, so changing the range is a plain
|
||||||
// recomposition and never depends on the widget session restarting.
|
// recomposition and never depends on the widget session restarting.
|
||||||
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone)
|
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 instances = ep.calendarRepository().instances(window).first()
|
||||||
val is24Hour = prefs.timeFormat.first()
|
val is24Hour = prefs.timeFormat.first()
|
||||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
||||||
val soften = prefs.softenCalendarColors.first()
|
val soften = prefs.softenCalendarColors.first()
|
||||||
return AgendaWidgetData.Ready(
|
return AgendaWidgetData.Ready(
|
||||||
today = anchor,
|
today = anchor,
|
||||||
days = groupAgendaDays(anchor, instances, zone),
|
days = groupAgendaDays(anchor, windowEnd, instances, zone),
|
||||||
is24Hour = is24Hour,
|
is24Hour = is24Hour,
|
||||||
soften = soften,
|
soften = soften,
|
||||||
weekStart = weekStart,
|
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.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.glance.ColorFilter
|
import androidx.glance.ColorFilter
|
||||||
@@ -14,9 +13,11 @@ import androidx.glance.GlanceModifier
|
|||||||
import androidx.glance.GlanceTheme
|
import androidx.glance.GlanceTheme
|
||||||
import androidx.glance.Image
|
import androidx.glance.Image
|
||||||
import androidx.glance.ImageProvider
|
import androidx.glance.ImageProvider
|
||||||
|
import androidx.glance.LocalSize
|
||||||
import androidx.glance.action.ActionParameters
|
import androidx.glance.action.ActionParameters
|
||||||
import androidx.glance.action.clickable
|
import androidx.glance.action.clickable
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.SizeMode
|
||||||
import androidx.glance.appwidget.action.ActionCallback
|
import androidx.glance.appwidget.action.ActionCallback
|
||||||
import androidx.glance.appwidget.action.actionRunCallback
|
import androidx.glance.appwidget.action.actionRunCallback
|
||||||
import androidx.glance.appwidget.action.actionStartActivity
|
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.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
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.anchorTodayIfMissing
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
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.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
|
import de.jeanlucmakiola.calendula.widget.scaleFor
|
||||||
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||||
import de.jeanlucmakiola.calendula.widget.today
|
import de.jeanlucmakiola.calendula.widget.today
|
||||||
@@ -105,6 +109,19 @@ class AgendaWidget : GlanceAppWidget() {
|
|||||||
|
|
||||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
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) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val data = context.loadAgendaWidgetData()
|
val data = context.loadAgendaWidgetData()
|
||||||
val dark = (context.resources.configuration.uiMode and
|
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. */
|
/** Flat row model so the [LazyColumn] can mix day headers and events. */
|
||||||
private sealed interface AgendaRow {
|
private sealed interface AgendaRow {
|
||||||
data class Header(val date: LocalDate, val today: LocalDate) : 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). */
|
/** "Nothing left today" line under an anchored, event-less today (#35). */
|
||||||
data class Placeholder(val date: LocalDate) : AgendaRow
|
data class Placeholder(val date: LocalDate) : AgendaRow
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
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(
|
Column(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(GlanceTheme.colors.surface)
|
.background(GlanceTheme.colors.surface)
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
) {
|
) {
|
||||||
AgendaHeader()
|
AgendaHeader(metrics)
|
||||||
Spacer(GlanceModifier.height(4.dp))
|
Spacer(GlanceModifier.height(4.dp))
|
||||||
when (data) {
|
when (data) {
|
||||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
|
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
|
||||||
is AgendaWidgetData.Ready -> {
|
is AgendaWidgetData.Ready -> {
|
||||||
// Range read reactively from per-instance Glance state (falls back
|
// Range read reactively from per-instance Glance state (falls back
|
||||||
// to the saved pref for a freshly placed widget), then the wide
|
// 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,
|
enabled = showToday,
|
||||||
)
|
)
|
||||||
if (visibleDays.isEmpty()) {
|
if (visibleDays.isEmpty()) {
|
||||||
WidgetMessage(R.string.agenda_empty_title)
|
WidgetMessage(R.string.agenda_empty_title, metrics)
|
||||||
} else {
|
} else {
|
||||||
val rows = buildList {
|
val rows = buildList {
|
||||||
visibleDays.forEach { day ->
|
visibleDays.forEach { day ->
|
||||||
@@ -185,22 +217,28 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
if (day.events.isEmpty()) {
|
if (day.events.isEmpty()) {
|
||||||
add(AgendaRow.Placeholder(day.date))
|
add(AgendaRow.Placeholder(day.date))
|
||||||
} else {
|
} 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()) {
|
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
items(rows.size) { index ->
|
items(rows.size) { index ->
|
||||||
when (val row = rows[index]) {
|
when (val row = rows[index]) {
|
||||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
|
||||||
is AgendaRow.Placeholder -> PlaceholderRow(row.date)
|
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
|
||||||
is AgendaRow.Event -> EventRow(
|
is AgendaRow.Event -> EventRow(
|
||||||
event = row.event,
|
event = row.event,
|
||||||
|
day = row.date,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
soften = data.soften,
|
soften = data.soften,
|
||||||
is24Hour = data.is24Hour,
|
is24Hour = data.is24Hour,
|
||||||
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
||||||
row.event.hasEnded(data.now),
|
row.event.hasEnded(data.now),
|
||||||
|
metrics = metrics,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,7 +250,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaHeader() {
|
private fun AgendaHeader(metrics: AgendaMetrics) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
Row(
|
Row(
|
||||||
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||||
@@ -224,7 +262,7 @@ private fun AgendaHeader() {
|
|||||||
text = context.getString(R.string.widget_agenda_title),
|
text = context.getString(R.string.widget_agenda_title),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
color = GlanceTheme.colors.primary,
|
color = GlanceTheme.colors.primary,
|
||||||
fontSize = 16.sp,
|
fontSize = metrics.title,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
),
|
),
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
@@ -237,6 +275,7 @@ private fun AgendaHeader() {
|
|||||||
resId = R.drawable.ic_widget_refresh,
|
resId = R.drawable.ic_widget_refresh,
|
||||||
contentDescription = context.getString(R.string.widget_refresh),
|
contentDescription = context.getString(R.string.widget_refresh),
|
||||||
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
|
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
|
||||||
|
metrics = metrics,
|
||||||
)
|
)
|
||||||
IconButton(
|
IconButton(
|
||||||
resId = R.drawable.ic_widget_add,
|
resId = R.drawable.ic_widget_add,
|
||||||
@@ -246,39 +285,50 @@ private fun AgendaHeader() {
|
|||||||
MainActivity.openCreateIntent(context, today(systemZone())),
|
MainActivity.openCreateIntent(context, today(systemZone())),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
metrics = metrics,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun IconButton(resId: Int, contentDescription: String, onClick: GlanceModifier) {
|
private fun IconButton(
|
||||||
|
resId: Int,
|
||||||
|
contentDescription: String,
|
||||||
|
onClick: GlanceModifier,
|
||||||
|
metrics: AgendaMetrics,
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier.size(40.dp).then(onClick),
|
modifier = GlanceModifier.size(metrics.iconBox).then(onClick),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
provider = ImageProvider(resId),
|
provider = ImageProvider(resId),
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||||
modifier = GlanceModifier.size(22.dp),
|
modifier = GlanceModifier.size(metrics.iconImage),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
Text(
|
Text(
|
||||||
text = agendaDayLabel(context, date, today),
|
text = agendaDayLabel(context, date, today),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
color = if (date == today) GlanceTheme.colors.primary
|
color = if (date == today) GlanceTheme.colors.primary
|
||||||
else GlanceTheme.colors.onSurfaceVariant,
|
else GlanceTheme.colors.onSurfaceVariant,
|
||||||
fontSize = 13.sp,
|
fontSize = metrics.dayHeader,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
),
|
),
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxWidth()
|
.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(
|
.clickable(
|
||||||
actionStartActivity(
|
actionStartActivity(
|
||||||
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
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.
|
* plain too (a stripe + text, not cards), so a card here would look out of place.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun PlaceholderRow(date: LocalDate) {
|
private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
Text(
|
Text(
|
||||||
text = context.getString(R.string.agenda_no_more_today),
|
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
|
modifier = GlanceModifier
|
||||||
.fillMaxWidth()
|
.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(
|
.clickable(
|
||||||
actionStartActivity(
|
actionStartActivity(
|
||||||
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
||||||
@@ -312,10 +367,12 @@ private fun PlaceholderRow(date: LocalDate) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun EventRow(
|
private fun EventRow(
|
||||||
event: EventInstance,
|
event: EventInstance,
|
||||||
|
day: LocalDate,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
soften: Boolean,
|
soften: Boolean,
|
||||||
is24Hour: Boolean,
|
is24Hour: Boolean,
|
||||||
dimmed: Boolean,
|
dimmed: Boolean,
|
||||||
|
metrics: AgendaMetrics,
|
||||||
) {
|
) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
@@ -328,7 +385,7 @@ private fun EventRow(
|
|||||||
Row(
|
Row(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 4.dp, vertical = 4.dp)
|
.padding(horizontal = ROW_H_PAD, vertical = metrics.rowVPad)
|
||||||
.clickable(
|
.clickable(
|
||||||
actionStartActivity(
|
actionStartActivity(
|
||||||
MainActivity.openEventIntent(
|
MainActivity.openEventIntent(
|
||||||
@@ -344,29 +401,29 @@ private fun EventRow(
|
|||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.width(5.dp)
|
.width(STRIPE_WIDTH)
|
||||||
.height(36.dp)
|
.height(metrics.stripeH)
|
||||||
.cornerRadius(3.dp)
|
.cornerRadius(3.dp)
|
||||||
.background(stripeColor),
|
.background(stripeColor),
|
||||||
) {}
|
) {}
|
||||||
Spacer(GlanceModifier.width(10.dp))
|
Spacer(GlanceModifier.width(STRIPE_GAP))
|
||||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = titleColor, fontSize = 14.sp),
|
style = TextStyle(color = titleColor, fontSize = metrics.eventTitle),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = eventTimeSummary(context, event, is24Hour),
|
text = eventTimeSummary(context, event, day, is24Hour),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WidgetMessage(resId: Int) {
|
private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
|
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
|
||||||
@@ -374,7 +431,7 @@ private fun WidgetMessage(resId: Int) {
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = context.getString(resId),
|
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
|
return if (relative != null) "$relative · $formatted" else formatted
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun eventTimeSummary(context: Context, event: EventInstance, is24Hour: Boolean): String {
|
private fun eventTimeSummary(
|
||||||
val time = if (event.isAllDay) {
|
context: Context,
|
||||||
context.getString(R.string.event_detail_all_day)
|
event: EventInstance,
|
||||||
} else {
|
day: LocalDate,
|
||||||
"${formatTime(event.start, is24Hour)} – ${formatTime(event.end, is24Hour)}"
|
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() }
|
val location = event.location?.takeIf { it.isNotBlank() }
|
||||||
return if (location != null) "$time · $location" else time
|
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.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
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.MonthWeek
|
||||||
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
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 colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
||||||
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
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))
|
Spacer(GlanceModifier.height(2.dp))
|
||||||
WeekdayHeader(weekStart = source.weekStart, colW = colW)
|
WeekdayHeader(weekStart = source.weekStart, colW = colW)
|
||||||
weeks.forEach { week ->
|
weeks.forEach { week ->
|
||||||
@@ -480,9 +481,13 @@ private fun PermissionMessage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun monthLabel(month: YearMonth): String {
|
// Locale.getDefault() rather than currentLocale(): Glance has no
|
||||||
val locale = Locale.getDefault()
|
// LocalConfiguration, and the widget is re-rendered on a configuration change
|
||||||
val name = java.time.Month.of(month.month.ordinal + 1)
|
// anyway (same convention as AgendaWidget).
|
||||||
.getDisplayName(JavaTextStyle.FULL, locale)
|
private fun monthLabel(month: YearMonth, currentYear: Int): String =
|
||||||
return "$name ${month.year}"
|
formatCalendarTitle(
|
||||||
}
|
date = java.time.LocalDate.of(month.year, month.month.ordinal + 1, 1),
|
||||||
|
locale = Locale.getDefault(),
|
||||||
|
currentYear = currentYear,
|
||||||
|
skeleton = "LLLL",
|
||||||
|
)
|
||||||
|
|||||||
@@ -475,21 +475,19 @@
|
|||||||
<string name="special_dates_default_title_anniversary">Jahrestag von {name} ({year})</string>
|
<string name="special_dates_default_title_anniversary">Jahrestag von {name} ({year})</string>
|
||||||
<string name="special_dates_default_title_custom">{name}</string>
|
<string name="special_dates_default_title_custom">{name}</string>
|
||||||
<string name="settings_week_numbers">Kalenderwochen</string>
|
<string name="settings_week_numbers">Kalenderwochen</string>
|
||||||
<string name="settings_week_numbers_summary">Kalenderwochen in Monatsansicht zeigen</string>
|
<string name="settings_week_numbers_summary">Kalenderwochen der Monatsansicht anzeigen</string>
|
||||||
<string name="calendars_export_title">Kalender exportieren</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_hint">Wähle aus, welche Kalender in die .ics-Datei aufgenommen werden sollen.</string>
|
||||||
<string name="calendars_export_action">Exportieren</string>
|
<string name="calendars_export_action">Exportieren</string>
|
||||||
<string name="calendars_restore_header">Wiederherstellen</string>
|
<string name="calendars_restore_header">Wiederherstellen</string>
|
||||||
<string name="calendars_restore_action">Aus .ics-Datei wiederherstellen</string>
|
<string name="calendars_restore_action">Aus .ics-Datei wiederherstellen</string>
|
||||||
<string name="calendars_restore_hint">Einträge von einem Backup oder einer anderen Kalender App importieren.</string>
|
<string name="calendars_restore_hint">Importiere Ereignisse aus einem Backup oder einer anderen Kalender-App.</string>
|
||||||
<string name="import_done_dedup_note">Bereits im Kalender vorhandene Ereignisse wurden übersprungen.</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_added_label">Hinzugefügt</string>
|
||||||
<string name="import_done_skipped_label">Dopplungen</string>
|
<string name="import_done_skipped_label">Duplikate</string>
|
||||||
<string name="import_button">Importieren</string>
|
<string name="import_button">Importieren</string>
|
||||||
<plurals name="import_title_count">
|
<plurals name="import_title_count">
|
||||||
<item quantity="one">%d Ereignis wird importiert</item>
|
<item quantity="one">%d Ereignis wird importiert</item>
|
||||||
<item quantity="other">%d Ereignisse werden importiert</item>
|
<item quantity="other">%d Ereignisse werden importiert</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="event_detail_duplicate">Duplikate</string>
|
|
||||||
<string name="reminder_day_tomorrow">Morgen</string>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -10,24 +10,24 @@
|
|||||||
<string name="app_tagline">Un calendrier moderne.</string>
|
<string name="app_tagline">Un calendrier moderne.</string>
|
||||||
<string name="state_loading">Chargement…</string>
|
<string name="state_loading">Chargement…</string>
|
||||||
<string name="state_retry">Réessayer</string>
|
<string name="state_retry">Réessayer</string>
|
||||||
<string name="state_failure_unknown">Une erreur s\'est produite.</string>
|
<string name="state_failure_unknown">Une erreur s\'est produit.</string>
|
||||||
<string name="state_failure_permission">Un accès au Calendrier est nécessaire.</string>
|
<string name="state_failure_permission">Un accès au Calendrier est nécessaire.</string>
|
||||||
<string name="state_failure_permission_action">Accorder l\'accès</string>
|
<string name="state_failure_permission_action">Autoriser l\'accès</string>
|
||||||
<string name="state_failure_no_calendars">Aucun calendrier configuré.</string>
|
<string name="state_failure_no_calendars">Aucun calendrier configuré.</string>
|
||||||
<string name="state_failure_no_calendars_action">Ouvrir les paramètres systèmes du calendrier</string>
|
<string name="state_failure_no_calendars_action">Ouvrir les paramètres du calendrier.</string>
|
||||||
<string name="state_failure_provider">Impossible de lire le calendrier.</string>
|
<string name="state_failure_provider">Ne peut pas lire le calendrier.</string>
|
||||||
<string name="permission_rationale_title">Accédez à tous vos évènements, en beauté</string>
|
<string name="permission_rationale_title">Tous les évènements ont été trouvés.</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_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="permission_request_button">Autoriser l\'accès au calendrier</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_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 système</string>
|
<string name="permission_open_settings_button">Ouvrir les paramètres.</string>
|
||||||
<string name="permission_retry_button">Essayer à nouveau</string>
|
<string name="permission_retry_button">Essayer à nouveau</string>
|
||||||
<string name="permission_benefit_private_title">Ne quitte pas votre appareil</string>
|
<string name="permission_benefit_private_title">Reste sur 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_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_sync_title">Tous vos calendriers, réunis</string>
|
||||||
<string name="permission_benefit_privacy_title">Aucun suivi, jamais</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="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_prev">Mois précédent</string>
|
||||||
<string name="month_next">Prochain mois</string>
|
<string name="month_next">Prochain mois</string>
|
||||||
@@ -153,130 +153,4 @@
|
|||||||
<string name="event_attendee_needs_action">Aucune réponse</string>
|
<string name="event_attendee_needs_action">Aucune réponse</string>
|
||||||
<string name="event_attendee_unknown">—</string>
|
<string name="event_attendee_unknown">—</string>
|
||||||
<string name="event_detail_reminders">Rappels</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>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -47,11 +47,11 @@
|
|||||||
<string name="event_share_failed">Nie udało się udostępnić tego wydarzenia.</string>
|
<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_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_body">Usunięto wydarzenie z Twojego kalendarza i wszystkich zsynchronizowanych urządzeń.</string>
|
||||||
<string name="event_delete_recurring_title">Usuń wydarzenie cykliczne</string>
|
<string name="event_delete_recurring_title">Usuń powtarzające się wydarzenie</string>
|
||||||
<string name="event_delete_option_occurrence">Tylko to wystąpienie</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_following">To i wszystkie następne wystąpienia</string>
|
||||||
<string name="event_delete_option_series">Wszystkie wystąpienia w serii</string>
|
<string name="event_delete_option_series">Wszystkie wystąpienia w serii</string>
|
||||||
<string name="event_edit_recurring_title">Edytuj wydarzenie cykliczne</string>
|
<string name="event_edit_recurring_title">Edytuj powtarzające się wydarzenie</string>
|
||||||
<string name="event_delete_failed">Nie udało się usunąć tego wydarzenia</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="event_delete_write_denied">Calendula wymaga uprawnień do zapisu, aby móc usuwać wydarzenia</string>
|
||||||
<string name="dialog_cancel">Anuluj</string>
|
<string name="dialog_cancel">Anuluj</string>
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
<string name="event_detail_description">Opis</string>
|
<string name="event_detail_description">Opis</string>
|
||||||
<string name="event_detail_attendees">Uczestnicy</string>
|
<string name="event_detail_attendees">Uczestnicy</string>
|
||||||
<string name="event_detail_recurrence">Powtarzanie</string>
|
<string name="event_detail_recurrence">Powtarzanie</string>
|
||||||
<string name="event_detail_recurring">Wydarzenie cykliczne</string>
|
<string name="event_detail_recurring">Powtarzające się wydarzenie</string>
|
||||||
<string name="recurrence_daily">Codziennie</string>
|
<string name="recurrence_daily">Codziennie</string>
|
||||||
<string name="recurrence_weekly">Co tydzień</string>
|
<string name="recurrence_weekly">Co tydzień</string>
|
||||||
<string name="recurrence_monthly">Co miesiąc</string>
|
<string name="recurrence_monthly">Co miesiąc</string>
|
||||||
@@ -297,224 +297,4 @@
|
|||||||
<string name="settings_agenda_range">Zakres agendy</string>
|
<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_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">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>
|
</resources>
|
||||||
|
|||||||
@@ -6,60 +6,4 @@
|
|||||||
<item>Days</item>
|
<item>Days</item>
|
||||||
<item>Weeks</item>
|
<item>Weeks</item>
|
||||||
</string-array>
|
</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>
|
</resources>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Calendula</string>
|
<string name="app_name">Calendula</string>
|
||||||
<string name="app_tagline">A modern calendar.</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) -->
|
<!-- Loading / Failure / Success generic strings (used across screens) -->
|
||||||
<string name="state_loading">Loading…</string>
|
<string name="state_loading">Loading…</string>
|
||||||
@@ -99,6 +103,18 @@
|
|||||||
<string name="event_edit_availability">Availability</string>
|
<string name="event_edit_availability">Availability</string>
|
||||||
<string name="event_edit_visibility">Visibility</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 -->
|
<!-- Event form — per-event color -->
|
||||||
<string name="event_edit_color">Color</string>
|
<string name="event_edit_color">Color</string>
|
||||||
<string name="event_edit_color_default">Calendar 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_weeks">weeks</string>
|
||||||
<string name="recurrence_unit_months">months</string>
|
<string name="recurrence_unit_months">months</string>
|
||||||
<string name="recurrence_unit_years">years</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_ends">Ends</string>
|
||||||
<string name="event_edit_recurrence_end_never">Never</string>
|
<string name="event_edit_recurrence_end_never">Never</string>
|
||||||
<string name="event_edit_recurrence_end_until">On a date</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_header_tomorrow">Tomorrow</string>
|
||||||
<string name="agenda_empty_title">You\'re all caught up</string>
|
<string name="agenda_empty_title">You\'re all caught up</string>
|
||||||
<string name="agenda_no_more_today">No more events today</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 -->
|
<!-- Event search -->
|
||||||
<string name="search_action">Search</string>
|
<string name="search_action">Search</string>
|
||||||
@@ -308,6 +333,10 @@
|
|||||||
<string name="settings_week_start_auto">Automatic</string>
|
<string name="settings_week_start_auto">Automatic</string>
|
||||||
<string name="settings_week_numbers">Week numbers</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_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">Time format</string>
|
||||||
<string name="settings_time_format_auto">Automatic</string>
|
<string name="settings_time_format_auto">Automatic</string>
|
||||||
<string name="settings_time_format_12h">12-hour (2:00 PM)</string>
|
<string name="settings_time_format_12h">12-hour (2:00 PM)</string>
|
||||||
@@ -343,6 +372,20 @@
|
|||||||
<item quantity="other">%d days</item>
|
<item quantity="other">%d days</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="settings_section_views">Views</string>
|
<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_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_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>
|
<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>
|
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
|
||||||
<!-- Hub category subtitles -->
|
<!-- Hub category subtitles -->
|
||||||
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
|
<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_event_form_subtitle">Default fields for new events</string>
|
||||||
<string name="settings_notifications_subtitle">Event reminders</string>
|
<string name="settings_notifications_subtitle">Event reminders</string>
|
||||||
<string name="settings_special_dates_subtitle">Contact birthdays & anniversaries</string>
|
<string name="settings_special_dates_subtitle">Contact birthdays & anniversaries</string>
|
||||||
|
|||||||
@@ -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,
|
isAllDay: Boolean = false,
|
||||||
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
|
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
|
||||||
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)),
|
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
|
@Test
|
||||||
fun `timed event resolves wall clock in the given zone`() {
|
fun `timed event resolves wall clock in the given zone`() {
|
||||||
@@ -30,6 +37,26 @@ class EventWriteMapperTest {
|
|||||||
assertThat(times.timezone).isEqualTo("Europe/Berlin")
|
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
|
@Test
|
||||||
fun `all-day event lives at UTC midnights with exclusive end`() {
|
fun `all-day event lives at UTC midnights with exclusive end`() {
|
||||||
val times = form(isAllDay = true).toWriteTimes(berlin)
|
val times = form(isAllDay = true).toWriteTimes(berlin)
|
||||||
@@ -96,8 +123,18 @@ class EventWriteMapperTest {
|
|||||||
|
|
||||||
private val seriesStart = 1_700_000_000_000L
|
private val seriesStart = 1_700_000_000_000L
|
||||||
|
|
||||||
private fun update(original: EventForm, updated: EventForm): Map<String, Any?> =
|
private fun update(
|
||||||
buildEventUpdateValues(original, updated, seriesStart, berlin)
|
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
|
@Test
|
||||||
fun `pristine form produces no values`() {
|
fun `pristine form produces no values`() {
|
||||||
@@ -105,6 +142,42 @@ class EventWriteMapperTest {
|
|||||||
assertThat(update(original, original.copy())).isEmpty()
|
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
|
@Test
|
||||||
fun `text-only edit writes just the changed columns`() {
|
fun `text-only edit writes just the changed columns`() {
|
||||||
val original = form()
|
val original = form()
|
||||||
@@ -156,6 +229,77 @@ class EventWriteMapperTest {
|
|||||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
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
|
@Test
|
||||||
fun `adding a recurrence keeps the times and writes rule plus duration`() {
|
fun `adding a recurrence keeps the times and writes rule plus duration`() {
|
||||||
val original = form()
|
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.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -125,6 +126,30 @@ class SettingsPrefsTest {
|
|||||||
assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week)
|
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
|
@Test
|
||||||
fun `garbage stored enum falls back to default`(@TempDir tempDir: Path) = runTest {
|
fun `garbage stored enum falls back to default`(@TempDir tempDir: Path) = runTest {
|
||||||
val store = newDataStore(tempDir)
|
val store = newDataStore(tempDir)
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ class EventFormTest {
|
|||||||
attendees: List<Attendee> = emptyList(),
|
attendees: List<Attendee> = emptyList(),
|
||||||
eventColor: Int? = null,
|
eventColor: Int? = null,
|
||||||
eventColorKey: String? = null,
|
eventColorKey: String? = null,
|
||||||
|
eventTimezone: String? = null,
|
||||||
): EventDetail = EventDetail(
|
): EventDetail = EventDetail(
|
||||||
instance = EventInstance(
|
instance = EventInstance(
|
||||||
instanceId = 1L,
|
instanceId = 1L,
|
||||||
@@ -138,6 +139,7 @@ class EventFormTest {
|
|||||||
accessLevel = accessLevel,
|
accessLevel = accessLevel,
|
||||||
eventColor = eventColor,
|
eventColor = eventColor,
|
||||||
eventColorKey = eventColorKey,
|
eventColorKey = eventColorKey,
|
||||||
|
eventTimezone = eventTimezone,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -157,6 +159,117 @@ class EventFormTest {
|
|||||||
assertThat(prefilled.description).isEqualTo("Body")
|
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
|
@Test
|
||||||
fun `toEditForm turns the exclusive all-day end into the last covered day`() {
|
fun `toEditForm turns the exclusive all-day end into the last covered day`() {
|
||||||
// 11th..13th = UTC midnights of the 11th and the (exclusive) 14th.
|
// 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.
|
UTC, so zones ahead of UTC can't leak an extra occurrence.
|
||||||
- All-day events are normalised to UTC midnights with an exclusive end.
|
- 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
|
## Save conflicts
|
||||||
|
|
||||||
No locking. `openForEdit` keeps an `EditSnapshot` — the prefilled form
|
No locking. `openForEdit` keeps an `EditSnapshot` — the prefilled form
|
||||||
|
|||||||
@@ -82,24 +82,9 @@ release work when a merge actually cuts a release:
|
|||||||
merged commit, build & sign the release APK with the **app key**, copy it into
|
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 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 +
|
the **repo key**, upload `repo/` + `metadata/`, then create the `vX.Y.Z` tag +
|
||||||
Gitea release (CHANGELOG section as notes), attach the R8 `mapping.txt`, and
|
Gitea release (CHANGELOG section as notes) and attach the R8 `mapping.txt`
|
||||||
mirror the release to **Codeberg** with the signed APK + a SHA-256 checksum
|
(best-effort). Ordinary merges with no version bump fall through `detect` and
|
||||||
(both best-effort). Ordinary merges with no version bump fall through `detect`
|
do nothing.
|
||||||
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
|
### Manual re-sign / recovery
|
||||||
|
|
||||||
@@ -118,7 +103,6 @@ for key rotation or repo recovery without publishing a new app version.
|
|||||||
| `FDROID_CONFIG_BASE64` | F-Droid `config.yml` (base64) — repo metadata + keystore passwords. |
|
| `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. |
|
| `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. |
|
| `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**
|
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
|
signs the index (its fingerprint is what users pin). Neither key nor the
|
||||||
|
|||||||
37
fastlane/metadata/android/en-US/changelogs/21600.txt
Normal file
37
fastlane/metadata/android/en-US/changelogs/21600.txt
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
### Added
|
||||||
|
- 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. The form shows the local equivalent
|
||||||
|
under the times, and the event's details keep your local time first with the
|
||||||
|
original noted beneath. Pick a zone from a searchable full-screen picker — by
|
||||||
|
city, IANA id, or abbreviation like "CEST". All-day events stay date-anchored
|
||||||
|
and carry no zone ([#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 corner
|
||||||
|
button for a permanent today icon in the top bar — always there, matching the
|
||||||
|
familiar calendar-app pattern. Leave it off to keep the floating button ([#60]).
|
||||||
|
- Show the app as "Calendar" in your launcher. A new App name setting
|
||||||
|
(Settings → Appearance) switches the launcher label from "Calendula" to the
|
||||||
|
generic "Calendar" for anyone who prefers it — handy on launchers that can't
|
||||||
|
rename apps themselves. Only the launcher name changes; your home-screen icon
|
||||||
|
may move to a new spot after switching ([#44]).
|
||||||
|
|
||||||
|
### 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]).
|
||||||
|
|
||||||
Submodule floret-kit updated: 2124227a7f...75b90d1b3b
Reference in New Issue
Block a user