Compare commits
54 Commits
v2.17.1
...
02f697faf7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02f697faf7 | ||
| 2dd81f1327 | |||
| 558aa2e69b | |||
|
|
34108b9c50 | ||
| 49a0c114c2 | |||
|
|
b1ce6f85cc | ||
|
|
ca5f87af11 | ||
|
|
7445092767 | ||
|
|
5ef66120bf | ||
|
|
bda86cd73d | ||
|
|
56953b0c50 | ||
|
|
7c8cd1e145 | ||
|
|
bce36956d1 | ||
|
|
e0fdadc7ca | ||
|
|
136167b54c | ||
| 02fd599e6b | |||
| b0412a96f3 | |||
|
|
06dbb89868 | ||
|
|
b002f45f29 | ||
|
|
c1eb702384 | ||
|
|
307517e85a | ||
|
|
a8ba227e8b | ||
|
|
cbfdfb9710 | ||
| d85003416e | |||
|
|
61c516149e | ||
|
|
560225cb8c | ||
|
|
2e8367afd4 | ||
| 8c40109341 | |||
|
|
4259c996b6 | ||
| d4218227bd | |||
|
|
6a4c5084a5 | ||
|
|
e38b9cde1f | ||
|
|
dbae8b5bcf | ||
|
|
2d06b7ad32 | ||
|
|
10a674deba | ||
|
|
93b9aa8f34 | ||
| ca826737ed | |||
| 5365cc67eb | |||
|
|
25e3640ba0 | ||
| 3f85bac74e | |||
| 5b83b9da73 | |||
| 3c8c555b94 | |||
| a6a6c0c714 | |||
|
|
66dbdc8913 | ||
|
|
6a125b7c73 | ||
|
|
44233cff35 | ||
|
|
5764bd889b | ||
|
|
a8a83a39d0 | ||
|
|
9557ca73ed | ||
|
|
9767fbbbaf | ||
|
|
9054742503 | ||
|
|
3d62036d79 | ||
|
|
34a8e91ea5 | ||
|
|
0097a9c534 |
@@ -264,9 +264,10 @@ jobs:
|
|||||||
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_v${VERSION}.apk"
|
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_v${VERSION}.apk"
|
||||||
|
|
||||||
# Per-version "What's New": ensure this version's changelog exists in the
|
# Per-version "What's New": ensure this version's changelog exists in the
|
||||||
# fastlane tree (committed at release-cut time for the official repo; this
|
# fastlane tree. The committed hand-written summary (kept under Play's
|
||||||
# regenerates it from CHANGELOG.md so the self-hosted repo never depends on
|
# 500-char cap) is used as-is; only if it is missing does the script fall
|
||||||
# the commit having happened). The transform below then carries it across.
|
# back to CHANGELOG.md, so the self-hosted repo never depends on the
|
||||||
|
# commit having happened. The transform below then carries it across.
|
||||||
- name: Ensure this version's changelog is in the fastlane tree
|
- name: Ensure this version's changelog is in the fastlane tree
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
run: bash scripts/sync_changelog_to_fastlane.sh
|
run: bash scripts/sync_changelog_to_fastlane.sh
|
||||||
@@ -635,9 +636,13 @@ jobs:
|
|||||||
FASTLANE_HIDE_CHANGELOG: '1'
|
FASTLANE_HIDE_CHANGELOG: '1'
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
test -f "dist/app-release.aab"
|
AAB="$GITHUB_WORKSPACE/dist/app-release.aab"
|
||||||
|
# Absolute, because a lane body runs from fastlane/, not the
|
||||||
|
# workspace root — a relative path resolves against the wrong
|
||||||
|
# directory there and 2.17.1 died on exactly that.
|
||||||
|
test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; }
|
||||||
bundle exec fastlane deploy \
|
bundle exec fastlane deploy \
|
||||||
aab:"dist/app-release.aab" \
|
aab:"$AAB" \
|
||||||
track:"$PLAY_TRACK" \
|
track:"$PLAY_TRACK" \
|
||||||
release_status:"$PLAY_RELEASE_STATUS" \
|
release_status:"$PLAY_RELEASE_STATUS" \
|
||||||
dry_run:"$PLAY_DRY_RUN"
|
dry_run:"$PLAY_DRY_RUN"
|
||||||
|
|||||||
161
CHANGELOG.md
161
CHANGELOG.md
@@ -7,6 +7,156 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.19.1] — 2026-08-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Drag to reschedule can be turned off.** Settings → Views → *Drag to
|
||||||
|
reschedule*, on by default. Off, no event can be picked up in the month, week
|
||||||
|
or day view — for anyone who kept moving appointments by accident while
|
||||||
|
scrolling ([#173]).
|
||||||
|
- An early Brazilian Portuguese translation. Calendula has started speaking
|
||||||
|
Brazilian Portuguese, contributed as a community translation through
|
||||||
|
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||||
|
About a third of the app is covered, so the rest still shows in English — you
|
||||||
|
can pick it under Settings → Language or in Android's per-app language
|
||||||
|
settings. Thanks to
|
||||||
|
[bkrz](https://weblate.dev.jeanlucmakiola.de/user/bkrz/) for getting it
|
||||||
|
started; help finishing it is very welcome.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **The language picker forgot which language was picked.** Android hands the
|
||||||
|
applied language back with its script filled in — Simplified Chinese goes in
|
||||||
|
as `zh-CN` and comes out as `zh-Hans-CN` — so after a restart no entry in
|
||||||
|
Settings → Language showed as selected, and the row above it named the
|
||||||
|
language the long way round. Only languages that imply a script were
|
||||||
|
affected.
|
||||||
|
|
||||||
|
## [2.19.0] — 2026-08-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Drag an event to reschedule it.** Press and hold an event in the month,
|
||||||
|
week or day view and drop it on another time or day — the only way to move an
|
||||||
|
appointment used to be opening it and editing its start and end by hand. The
|
||||||
|
block follows your finger, the timeline scrolls when you carry it to an edge,
|
||||||
|
and the event's length is preserved: a 90-minute meeting dropped on 09:00 ends
|
||||||
|
at 10:30. In week and day view the drop snaps to 15-minute steps; in month
|
||||||
|
view it moves the event to the day you dropped it on and leaves the time
|
||||||
|
alone. A confirmation names the new slot and offers **Undo** wherever the move
|
||||||
|
can be taken back in one step. Events you can't edit — read-only calendars, and
|
||||||
|
birthdays and anniversaries mirrored from your contacts — can't be picked up.
|
||||||
|
Rescheduling isn't pointer-only: a screen reader gets a **Move…** action on
|
||||||
|
every event that can be moved ([#68]).
|
||||||
|
- Dragging a **recurring** event asks what it should apply to, the same *this
|
||||||
|
event / this and following / all events* choice a save does. Where moving the
|
||||||
|
whole series would change which weekday it falls on, the rule is rewritten to
|
||||||
|
match — a weekly Monday event dropped on a Wednesday becomes a weekly
|
||||||
|
Wednesday event. Where it can't be rewritten safely, the prompt says so and
|
||||||
|
offers to move just the one occurrence, rather than quietly leaving the
|
||||||
|
series behind ([#68]).
|
||||||
|
- **Delete several search results at once.** Long-press a hit in search to enter
|
||||||
|
selection mode, tap the rest (or **Select all**), and delete the lot in one
|
||||||
|
action — finding and removing, say, every leftover event with the same title
|
||||||
|
no longer means opening them one at a time. Recurring hits in the batch ask
|
||||||
|
once for the whole selection instead of once each, results on read-only
|
||||||
|
calendars can't be selected, and the deletion is confirmed before it runs
|
||||||
|
([#80]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **First launch is now a short wizard** instead of a single permission screen.
|
||||||
|
It counts its steps, each one can be stepped back out of, and beyond granting
|
||||||
|
calendar access it offers two things worth deciding once: turning on automatic
|
||||||
|
backup — chosen inline, folder picker and all, since events that live only on
|
||||||
|
the phone are lost with it — and choosing which view Calendula opens on, with
|
||||||
|
a live preview of the month, week, day and agenda layouts rather than four
|
||||||
|
names in a list. Both are skippable, everything chosen is changeable in
|
||||||
|
Settings afterwards, and the backup step is offered only where nothing
|
||||||
|
writable is being synced anywhere. Existing installs are not re-onboarded —
|
||||||
|
revoking and re-granting the calendar permission later does not restart the
|
||||||
|
wizard ([#163]).
|
||||||
|
- The one-time notice about calendars switched off for this device is now a step
|
||||||
|
in the flow rather than a dialog thrown over the app on first open.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Removed the deprecated `statusBarColor` / `navigationBarColor` theme
|
||||||
|
attributes the Play Console flags on Android 15 and up, where they no longer
|
||||||
|
do anything. The launch backdrop now picks its status-bar icons from the
|
||||||
|
light/dark resource instead, so they stay legible over it either way
|
||||||
|
([#123]).
|
||||||
|
|
||||||
|
## [2.18.1] — 2026-08-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Tapping an empty slot in week or day view creates the event at the hour you
|
||||||
|
tapped, after the timeline has been zoomed. Pinching the day taller or shorter
|
||||||
|
— or changing **Hour height** in Settings — left the tap still being measured
|
||||||
|
against the old spacing, so the new event landed at some other hour entirely
|
||||||
|
([#148]).
|
||||||
|
|
||||||
|
## [2.18.0] — 2026-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- A new event no longer always lasts an hour. **Settings → New event form →
|
||||||
|
Default duration** sets how long one opens, and each calendar may keep its own
|
||||||
|
length underneath — 8 hours for the calendar you keep work shifts in, 30
|
||||||
|
minutes for the one you book calls in. A calendar without its own length
|
||||||
|
follows the default, switching calendars mid-form re-stretches the event, and
|
||||||
|
setting an end time by hand keeps it. An event another app hands over with only
|
||||||
|
a start gets the default too; one that names its own end — an `.ics` file, a
|
||||||
|
duplicate — keeps that length. All-day events are unaffected ([#54]).
|
||||||
|
- Week and day view can be set to show more or less of the day at once, under
|
||||||
|
Settings → Views → Week & day → **Hour height**. **Fit whole day** sizes an
|
||||||
|
hour to your screen so all 24 hours are visible without scrolling — on a tall
|
||||||
|
phone the old fixed spacing showed only about half a day, so appointments
|
||||||
|
could sit below the fold all week. Compact and Comfortable are fixed steps
|
||||||
|
either side of the previous spacing, which stays the default. Both views share
|
||||||
|
the setting ([#56]).
|
||||||
|
- Week and day view can be **pinched** with two fingers to set the hour height
|
||||||
|
directly, anywhere between and beyond the named steps. The time under your
|
||||||
|
fingers stays put as it zooms, so you keep your place in the day. A pinched
|
||||||
|
height is remembered and appears as **Custom** in the Hour height setting, so
|
||||||
|
tapping a named step there takes you back to it ([#56]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- The date in the top bar is now the way to jump: tapping the month, week or day
|
||||||
|
title opens the same date picker the sidebar's **Jump to date** offers, seeded
|
||||||
|
on whatever the bar is naming. A drop-down caret marks it as tappable. The
|
||||||
|
sidebar entry stays where it is ([#57]).
|
||||||
|
- A reminder in the status bar is now Calendula's own mark — the calendar with
|
||||||
|
the bloom — instead of the generic calendar glyph it shared with the system's
|
||||||
|
date surfaces and every other calendar app. The status bar draws that icon as
|
||||||
|
a plain silhouette, so its shape is the only thing that could tell them apart
|
||||||
|
([#83]).
|
||||||
|
- Event blocks now only draw text they can draw whole. One too short for a full
|
||||||
|
line shows no title rather than a sliced one, a block that cannot fit both its
|
||||||
|
title and its time keeps the title, and a block too narrow to hold more than a
|
||||||
|
syllable stays on one ellipsised line instead of stacking letters down the
|
||||||
|
block. Tapping and the spoken description are unchanged ([#56]).
|
||||||
|
- Calendar colours are reworked so text on an event is always readable. They
|
||||||
|
were shaped by a brightness setting that does not match what the eye sees, so
|
||||||
|
whether a block got dark or light text depended on which hue you happened to
|
||||||
|
pick — an orange calendar took dark text while a red one beside it took light
|
||||||
|
— and colours landing in between were hard to read either way. Each colour now
|
||||||
|
keeps its hue and is moved clear of that middle: most become deep blocks with
|
||||||
|
light text, while naturally pale colours such as yellow stay pale and take
|
||||||
|
dark text, rather than being forced into a muddy brown. Dots, stripes and
|
||||||
|
icons are tuned separately from blocks, so they stay visible against the
|
||||||
|
background instead of sharing a colour meant to sit behind text. The setting
|
||||||
|
is now called **Harmonise calendar colours**; turning it off still shows the
|
||||||
|
raw colours from your calendar source ([#21], [#36]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The back gesture closes the sidebar instead of the app. With the drawer open,
|
||||||
|
swiping back left Calendula altogether rather than putting the sidebar away
|
||||||
|
([#114]).
|
||||||
|
- The sidebar lines up. "Calendula", "View" and "Calendars" now share the left
|
||||||
|
edge of the rows under them, and the view, jump-to-date, Settings and calendar
|
||||||
|
rows sit on one vertical axis instead of each icon finding its own — the same
|
||||||
|
alignment the Settings screens use ([#114]).
|
||||||
|
- The status- and navigation-bar icons follow Calendula's own light/dark choice.
|
||||||
|
Setting the app dark while the system stayed light — or the other way round —
|
||||||
|
left the clock and battery drawn for the system's theme, so they could sit
|
||||||
|
near-invisible against the app's own bar ([#70]).
|
||||||
|
|
||||||
## [2.17.1] — 2026-07-30
|
## [2.17.1] — 2026-07-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1249,7 +1399,18 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#79]: https://codeberg.org/jlmakiola/calendula/issues/79
|
[#79]: https://codeberg.org/jlmakiola/calendula/issues/79
|
||||||
[#81]: https://codeberg.org/jlmakiola/calendula/issues/81
|
[#81]: https://codeberg.org/jlmakiola/calendula/issues/81
|
||||||
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
|
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
|
||||||
|
[#83]: https://codeberg.org/jlmakiola/calendula/issues/83
|
||||||
[#87]: https://codeberg.org/jlmakiola/calendula/issues/87
|
[#87]: https://codeberg.org/jlmakiola/calendula/issues/87
|
||||||
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
|
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
|
||||||
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
|
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
|
||||||
[#69]: https://codeberg.org/jlmakiola/calendula/issues/69
|
[#69]: https://codeberg.org/jlmakiola/calendula/issues/69
|
||||||
|
[#54]: https://codeberg.org/jlmakiola/calendula/issues/54
|
||||||
|
[#57]: https://codeberg.org/jlmakiola/calendula/issues/57
|
||||||
|
[#56]: https://codeberg.org/jlmakiola/calendula/issues/56
|
||||||
|
[#114]: https://codeberg.org/jlmakiola/calendula/issues/114
|
||||||
|
[#148]: https://codeberg.org/jlmakiola/calendula/issues/148
|
||||||
|
[#68]: https://codeberg.org/jlmakiola/calendula/issues/68
|
||||||
|
[#80]: https://codeberg.org/jlmakiola/calendula/issues/80
|
||||||
|
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
||||||
|
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
||||||
|
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
|
||||||
|
|||||||
@@ -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 = 21701
|
versionCode = 21902
|
||||||
versionName = "2.17.1"
|
versionName = "2.19.2"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.permission
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
@@ -2,9 +2,11 @@ package de.jeanlucmakiola.calendula
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
|
import androidx.activity.SystemBarStyle
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
@@ -12,6 +14,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -29,11 +32,14 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
|||||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
||||||
|
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
||||||
import de.jeanlucmakiola.floret.components.DebugRibbon
|
import de.jeanlucmakiola.floret.components.DebugRibbon
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
@@ -50,9 +56,17 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** A prefilled create form from an external launch, with the source it came from. */
|
||||||
|
private data class InsertRequest(val form: EventForm, val source: ImportSource)
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
// Which of light/dark the system bars are drawn for. The styles installed
|
||||||
|
// in onCreate read this field live, so androidx's config-change replay
|
||||||
|
// picks up the in-app override instead of the night resource qualifier.
|
||||||
|
private var systemBarsDark = false
|
||||||
|
|
||||||
// The occurrence a reminder notification was tapped for (eventId, begin,
|
// The occurrence a reminder notification was tapped for (eventId, begin,
|
||||||
// end — the detail screen's key shape). singleTop + onNewIntent route a
|
// end — the detail screen's key shape). singleTop + onNewIntent route a
|
||||||
// tap into the running activity; CalendarHost consumes and clears it.
|
// tap into the running activity; CalendarHost consumes and clears it.
|
||||||
@@ -69,7 +83,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// A prefilled new-event form from an external ACTION_INSERT launch (another
|
// A prefilled new-event form from an external ACTION_INSERT launch (another
|
||||||
// app/widget asking us to create an event, issue #30). Consumed once by
|
// app/widget asking us to create an event, issue #30). Consumed once by
|
||||||
// CalendarHost, which opens it in the create form for review.
|
// CalendarHost, which opens it in the create form for review.
|
||||||
private var requestedInsertForm by mutableStateOf<EventForm?>(null)
|
private var requestedInsert by mutableStateOf<InsertRequest?>(null)
|
||||||
|
|
||||||
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
|
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
|
||||||
// opens the occurrence in the edit form. Same occurrence-key shape as the
|
// opens the occurrence in the edit form. Same occurrence-key shape as the
|
||||||
@@ -96,11 +110,13 @@ class MainActivity : AppCompatActivity() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
enableEdgeToEdge()
|
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
||||||
|
Configuration.UI_MODE_NIGHT_YES
|
||||||
|
applyEdgeToEdge()
|
||||||
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||||
requestedNav = intent.navRequestOrNull()
|
requestedNav = intent.navRequestOrNull()
|
||||||
requestedImportUri = intent.importUriOrNull()
|
requestedImportUri = intent.importUriOrNull()
|
||||||
requestedInsertForm = intent.insertFormOrNull()
|
requestedInsert = intent.insertRequestOrNull()
|
||||||
requestedEditKey = intent.editEventKeyOrNull()
|
requestedEditKey = intent.editEventKeyOrNull()
|
||||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
setContent {
|
setContent {
|
||||||
@@ -113,6 +129,14 @@ class MainActivity : AppCompatActivity() {
|
|||||||
ThemeMode.LIGHT -> false
|
ThemeMode.LIGHT -> false
|
||||||
ThemeMode.DARK -> true
|
ThemeMode.DARK -> true
|
||||||
}
|
}
|
||||||
|
// onCreate can only see the night resource qualifier, not the in-app
|
||||||
|
// override — re-apply from the resolved theme so the bar icons follow
|
||||||
|
// the app's light/dark choice.
|
||||||
|
DisposableEffect(darkTheme) {
|
||||||
|
systemBarsDark = darkTheme
|
||||||
|
applyEdgeToEdge()
|
||||||
|
onDispose {}
|
||||||
|
}
|
||||||
// The app-wide clock convention: the time-format preference resolved
|
// The app-wide clock convention: the time-format preference resolved
|
||||||
// against the device's 24-hour system setting, provided once here so
|
// against the device's 24-hour system setting, provided once here so
|
||||||
// every time label reads it via LocalUse24HourFormat.
|
// every time label reads it via LocalUse24HourFormat.
|
||||||
@@ -125,6 +149,13 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// re-import stamp AppFontSettings carries, so replacing the file
|
// re-import stamp AppFontSettings carries, so replacing the file
|
||||||
// behind an active "custom" token still refreshes — changes;
|
// behind an active "custom" token still refreshes — changes;
|
||||||
// "system for both" returns the default scale untouched.
|
// "system for both" returns the default scale untouched.
|
||||||
|
// The timeline scale plus the pinch in flight over it (#56). Held
|
||||||
|
// here, above the calendar views, so a zoom survives paging between
|
||||||
|
// weeks and switching between the week and day view.
|
||||||
|
val timelineZoom = rememberTimelineZoom(
|
||||||
|
stored = settings.timelineScale,
|
||||||
|
onPersist = settingsViewModel::setTimelineScale,
|
||||||
|
)
|
||||||
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
||||||
val typography = remember(fonts, context) {
|
val typography = remember(fonts, context) {
|
||||||
calendulaTypography(
|
calendulaTypography(
|
||||||
@@ -141,6 +172,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalUse24HourFormat provides use24Hour,
|
LocalUse24HourFormat provides use24Hour,
|
||||||
LocalShowHourLines provides settings.showHourLines,
|
LocalShowHourLines provides settings.showHourLines,
|
||||||
|
LocalTimelineZoom provides timelineZoom,
|
||||||
LocalSoftenColors provides settings.softenColors,
|
LocalSoftenColors provides settings.softenColors,
|
||||||
) {
|
) {
|
||||||
RootScreen(
|
RootScreen(
|
||||||
@@ -151,8 +183,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
onWidgetNavConsumed = { requestedNav = null },
|
onWidgetNavConsumed = { requestedNav = null },
|
||||||
requestedImportUri = requestedImportUri,
|
requestedImportUri = requestedImportUri,
|
||||||
onImportConsumed = { requestedImportUri = null },
|
onImportConsumed = { requestedImportUri = null },
|
||||||
requestedInsertForm = requestedInsertForm,
|
requestedInsertForm = requestedInsert?.form,
|
||||||
onInsertConsumed = { requestedInsertForm = null },
|
requestedInsertSource = requestedInsert?.source ?: ImportSource.Insert,
|
||||||
|
onInsertConsumed = { requestedInsert = null },
|
||||||
requestedEditKey = requestedEditKey,
|
requestedEditKey = requestedEditKey,
|
||||||
onEditKeyConsumed = { requestedEditKey = null },
|
onEditKeyConsumed = { requestedEditKey = null },
|
||||||
)
|
)
|
||||||
@@ -181,6 +214,19 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the transparent edge-to-edge bars for the current [systemBarsDark].
|
||||||
|
* Both scrims are transparent because API 29+ enforces its own contrast and
|
||||||
|
* ignores them anyway.
|
||||||
|
*/
|
||||||
|
private fun applyEdgeToEdge() {
|
||||||
|
val transparent = android.graphics.Color.TRANSPARENT
|
||||||
|
enableEdgeToEdge(
|
||||||
|
statusBarStyle = SystemBarStyle.auto(transparent, transparent) { systemBarsDark },
|
||||||
|
navigationBarStyle = SystemBarStyle.auto(transparent, transparent) { systemBarsDark },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
// Reaching a running UI means startup succeeded; reset the loop trail.
|
// Reaching a running UI means startup succeeded; reset the loop trail.
|
||||||
@@ -192,7 +238,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
|
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
|
||||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||||
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
intent.insertRequestOrNull()?.let { requestedInsert = it }
|
||||||
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,9 +265,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
|
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
|
||||||
* event", i.e. create). The new event's fields ride as CalendarContract
|
* event", i.e. create). The new event's fields ride as CalendarContract
|
||||||
* extras; anything omitted falls back to the in-app "new event" defaults in
|
* extras; anything omitted falls back to the in-app "new event" defaults in
|
||||||
* [buildInsertEventForm].
|
* [buildInsertEventForm]. An intent that names no end time leaves the length
|
||||||
|
* to the default-duration setting ([ImportSource.InsertOpenEnded], #54).
|
||||||
*/
|
*/
|
||||||
private fun Intent.insertFormOrNull(): EventForm? {
|
private fun Intent.insertRequestOrNull(): InsertRequest? {
|
||||||
// ACTION_EDIT / ACTION_INSERT_OR_EDIT on an existing event route to the
|
// ACTION_EDIT / ACTION_INSERT_OR_EDIT on an existing event route to the
|
||||||
// edit form instead ([editEventKeyOrNull]); an id-less one is a create,
|
// edit form instead ([editEventKeyOrNull]); an id-less one is a create,
|
||||||
// as is any plain ACTION_INSERT.
|
// as is any plain ACTION_INSERT.
|
||||||
@@ -229,9 +276,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
((action == Intent.ACTION_EDIT || action == Intent.ACTION_INSERT_OR_EDIT) &&
|
((action == Intent.ACTION_EDIT || action == Intent.ACTION_INSERT_OR_EDIT) &&
|
||||||
editEventKeyOrNull() == null)
|
editEventKeyOrNull() == null)
|
||||||
if (!isCreate) return null
|
if (!isCreate) return null
|
||||||
return buildInsertEventForm(
|
val beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME)
|
||||||
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
val endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME)
|
||||||
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
|
val form = buildInsertEventForm(
|
||||||
|
beginMillis = beginMillis,
|
||||||
|
endMillis = endMillis,
|
||||||
isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false),
|
isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false),
|
||||||
title = getStringExtra(CalendarContract.Events.TITLE),
|
title = getStringExtra(CalendarContract.Events.TITLE),
|
||||||
description = getStringExtra(CalendarContract.Events.DESCRIPTION),
|
description = getStringExtra(CalendarContract.Events.DESCRIPTION),
|
||||||
@@ -240,6 +289,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
zone = TimeZone.currentSystemDefault(),
|
zone = TimeZone.currentSystemDefault(),
|
||||||
now = Clock.System.now(),
|
now = Clock.System.now(),
|
||||||
)
|
)
|
||||||
|
// An end the intent didn't name — or one [buildInsertEventForm] drops for
|
||||||
|
// landing before the start — leaves the length to the setting.
|
||||||
|
val namesEnd = beginMillis != null && endMillis != null && endMillis >= beginMillis
|
||||||
|
return InsertRequest(form, if (namesEnd) ImportSource.Insert else ImportSource.InsertOpenEnded)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A Long extra's value, or null when the extra is absent. */
|
/** A Long extra's value, or null when the extra is absent. */
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
|
|||||||
import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventSearch
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
@@ -62,12 +64,14 @@ interface CalendarDataSource {
|
|||||||
fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail?
|
fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Master/one-off events whose title, description or location contains
|
* Master/one-off events that may match [query], across all calendars: every
|
||||||
* [query] (case-insensitive), across all calendars, newest first. Reads the
|
* whitespace-separated token has to appear in the title, description or
|
||||||
* Events table directly so the search is unbounded in time; exception rows
|
* location. Reads the Events table directly so the search is unbounded in
|
||||||
* are excluded (see [SearchProjection]). [query] is assumed non-blank.
|
* time; exception rows are excluded (see [SearchProjection]). A deliberate
|
||||||
|
* superset — [EventSearch] makes the final call and orders the hits.
|
||||||
|
* [query] is assumed non-blank.
|
||||||
*/
|
*/
|
||||||
fun searchEvents(query: String): List<EventInstance>
|
fun searchEvents(query: String): List<SearchCandidate>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event-colour palette the calendar's account publishes
|
* The event-colour palette the calendar's account publishes
|
||||||
@@ -585,20 +589,23 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun searchEvents(query: String): List<EventInstance> {
|
override fun searchEvents(query: String): List<SearchCandidate> {
|
||||||
ensureObserversRegistered()
|
ensureObserversRegistered()
|
||||||
val trimmed = query.trim()
|
val tokens = EventSearch.tokenize(query).take(MAX_SEARCH_TOKENS)
|
||||||
if (trimmed.isEmpty()) return emptyList()
|
if (tokens.isEmpty()) return emptyList()
|
||||||
// Escape the SQL LIKE wildcards so a literal % or _ in the query matches
|
// Only a pre-filter: EventSearch re-checks every token, including the
|
||||||
// itself instead of acting as a wildcard.
|
// ones the cap above dropped.
|
||||||
val escaped = trimmed
|
val patterns = tokens.map { likePatterns(it) }
|
||||||
.replace("\\", "\\\\")
|
val match = patterns.joinToString(" AND ") { variants ->
|
||||||
.replace("%", "\\%")
|
variants.joinToString(" OR ", prefix = "(", postfix = ")") {
|
||||||
.replace("_", "\\_")
|
"${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
|
||||||
val like = "%$escaped%"
|
|
||||||
val match = "${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
|
|
||||||
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
|
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
|
||||||
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
|
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val args = patterns
|
||||||
|
.flatMap { variants -> variants.flatMap { pattern -> List(3) { pattern } } }
|
||||||
|
.toTypedArray()
|
||||||
val selection = "($match) AND " +
|
val selection = "($match) AND " +
|
||||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||||
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
|
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
|
||||||
@@ -606,19 +613,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
CalendarContract.Events.CONTENT_URI,
|
CalendarContract.Events.CONTENT_URI,
|
||||||
SearchProjection.COLUMNS,
|
SearchProjection.COLUMNS,
|
||||||
selection,
|
selection,
|
||||||
arrayOf(like, like, like),
|
args,
|
||||||
CalendarContract.Events.DTSTART + " DESC",
|
CalendarContract.Events.DTSTART + " DESC",
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
val reader = CursorColumnReader(c)
|
val reader = CursorColumnReader(c)
|
||||||
val out = ArrayList<EventInstance>(c.count)
|
val out = ArrayList<SearchCandidate>(c.count)
|
||||||
while (c.moveToNext()) {
|
while (c.moveToNext()) {
|
||||||
|
val description = reader.getString(SearchProjection.IDX_DESCRIPTION)
|
||||||
val base = reader.toSearchResult() ?: continue
|
val base = reader.toSearchResult() ?: continue
|
||||||
// A recurring master's DTSTART is the series start; show its
|
// A recurring master's DTSTART is the series start; date the row
|
||||||
// nearest occurrence instead so the date is the one the user
|
// by its nearest occurrence instead.
|
||||||
// actually cares about (and sorting reflects it).
|
val event = if (base.isRecurring) {
|
||||||
val recurring = !reader.getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
|
||||||
!reader.getString(SearchProjection.IDX_RDATE).isNullOrEmpty()
|
|
||||||
out += if (recurring) {
|
|
||||||
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
|
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
|
||||||
base.copy(
|
base.copy(
|
||||||
start = begin.toKotlinInstantFromEpochMillis(),
|
start = begin.toKotlinInstantFromEpochMillis(),
|
||||||
@@ -628,11 +633,44 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
base
|
base
|
||||||
}
|
}
|
||||||
|
// The raw title, so matching can't catch on the placeholder an
|
||||||
|
// untitled event is drawn with.
|
||||||
|
out += SearchCandidate(
|
||||||
|
event = event,
|
||||||
|
description = description,
|
||||||
|
title = reader.getString(SearchProjection.IDX_TITLE),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One token as the `LIKE` patterns to OR together. SQLite folds case for
|
||||||
|
* ASCII only, so a token with cased non-ASCII letters is also queried lower-,
|
||||||
|
* upper- and title-cased, which is how "ärzte" reaches "Ärzte". Wildcarding
|
||||||
|
* those letters away instead would make "москва" `%______%` — a full scan.
|
||||||
|
*/
|
||||||
|
private fun likePatterns(token: String): List<String> {
|
||||||
|
if (token.none { it.code > 127 && (it.isUpperCase() || it.isLowerCase()) }) {
|
||||||
|
return listOf(likePattern(token))
|
||||||
|
}
|
||||||
|
val lower = token.lowercase()
|
||||||
|
return listOf(token, lower, token.uppercase(), lower.replaceFirstChar(Char::uppercaseChar))
|
||||||
|
.distinct()
|
||||||
|
.map(::likePattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun likePattern(token: String): String {
|
||||||
|
val sb = StringBuilder("%")
|
||||||
|
for (c in token) {
|
||||||
|
// A literal wildcard from the query matches itself.
|
||||||
|
if (c == '%' || c == '_' || c == '\\') sb.append('\\')
|
||||||
|
sb.append(c)
|
||||||
|
}
|
||||||
|
return sb.append('%').toString()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The occurrence of [eventId] nearest to now: the soonest upcoming one
|
* The occurrence of [eventId] nearest to now: the soonest upcoming one
|
||||||
* within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one
|
* within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one
|
||||||
@@ -1593,5 +1631,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
* next fires beyond it falls back to its series-start date.
|
* next fires beyond it falls back to its series-start date.
|
||||||
*/
|
*/
|
||||||
const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000
|
const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokens the SQL pre-filter is built from. Bounds the statement for a
|
||||||
|
* pasted paragraph; the ranker still requires every token, so a capped
|
||||||
|
* search returns fewer rows to check, never more hits.
|
||||||
|
*/
|
||||||
|
const val MAX_SEARCH_TOKENS = 8
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
@@ -17,11 +18,11 @@ interface CalendarRepository {
|
|||||||
suspend fun eventDetail(eventId: Long): EventDetail
|
suspend fun eventDetail(eventId: Long): EventDetail
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Events whose title, description or location contains [query], with hidden
|
* Candidate matches for [query] with hidden calendars removed; empty when
|
||||||
* calendars removed and newest first. Empty when [query] is blank. Searches
|
* [query] is blank. Searches the whole history/future and leaves matching
|
||||||
* the whole history/future (see [CalendarDataSource.searchEvents]).
|
* and ranking to [de.jeanlucmakiola.calendula.domain.EventSearch].
|
||||||
*/
|
*/
|
||||||
suspend fun searchEvents(query: String): List<EventInstance>
|
suspend fun searchEvents(query: String): List<SearchCandidate>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event-colour palette a calendar's account publishes; empty when it
|
* The event-colour palette a calendar's account publishes; empty when it
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -162,13 +163,13 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
?: throw NoSuchEventException(eventId)
|
?: throw NoSuchEventException(eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
override suspend fun searchEvents(query: String): List<SearchCandidate> = withContext(io) {
|
||||||
if (query.isBlank()) return@withContext emptyList()
|
if (query.isBlank()) return@withContext emptyList()
|
||||||
val excluded = prefs.hiddenCalendarIds.first() +
|
val excluded = prefs.hiddenCalendarIds.first() +
|
||||||
prefs.pendingDisabledCalendarIds.first() +
|
prefs.pendingDisabledCalendarIds.first() +
|
||||||
invisibleCalendarIds()
|
invisibleCalendarIds()
|
||||||
dataSource.searchEvents(query)
|
dataSource.searchEvents(query)
|
||||||
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
|
.let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
|
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||||
eventColor = eventColor,
|
eventColor = eventColor,
|
||||||
eventColorKey = eventColorKey,
|
eventColorKey = eventColorKey,
|
||||||
|
isException = !isNull(EventDetailProjection.IDX_ORIGINAL_ID),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ internal object EventDetailProjection {
|
|||||||
// Recurring rows carry DURATION instead of DTEND; the detail screen
|
// Recurring rows carry DURATION instead of DTEND; the detail screen
|
||||||
// needs it to render a series opened without a named occurrence.
|
// needs it to render a series opened without a named occurrence.
|
||||||
CalendarContract.Events.DURATION,
|
CalendarContract.Events.DURATION,
|
||||||
|
// Non-null on a modified-occurrence exception row; "no RRULE" alone
|
||||||
|
// can't tell an exception from a master (#68).
|
||||||
|
CalendarContract.Events.ORIGINAL_ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_EVENT_ID = 0
|
const val IDX_EVENT_ID = 0
|
||||||
@@ -110,6 +113,7 @@ internal object EventDetailProjection {
|
|||||||
const val IDX_SELF_ATTENDEE_STATUS = 16
|
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||||
const val IDX_EVENT_COLOR_KEY = 17
|
const val IDX_EVENT_COLOR_KEY = 17
|
||||||
const val IDX_DURATION = 18
|
const val IDX_DURATION = 18
|
||||||
|
const val IDX_ORIGINAL_ID = 19
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -176,6 +180,8 @@ internal object SearchProjection {
|
|||||||
// display its nearest occurrence, not the series-start DTSTART.
|
// display its nearest occurrence, not the series-start DTSTART.
|
||||||
CalendarContract.Events.RRULE,
|
CalendarContract.Events.RRULE,
|
||||||
CalendarContract.Events.RDATE,
|
CalendarContract.Events.RDATE,
|
||||||
|
// Excerpted, not just filtered on: a hit has to show what it matched.
|
||||||
|
CalendarContract.Events.DESCRIPTION,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_ID = 0
|
const val IDX_ID = 0
|
||||||
@@ -190,6 +196,7 @@ internal object SearchProjection {
|
|||||||
const val IDX_LOCATION = 9
|
const val IDX_LOCATION = 9
|
||||||
const val IDX_RRULE = 10
|
const val IDX_RRULE = 10
|
||||||
const val IDX_RDATE = 11
|
const val IDX_RDATE = 11
|
||||||
|
const val IDX_DESCRIPTION = 12
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -40,5 +40,7 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
|||||||
isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
|
isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
|
||||||
color = color,
|
color = color,
|
||||||
location = getString(SearchProjection.IDX_LOCATION),
|
location = getString(SearchProjection.IDX_LOCATION),
|
||||||
|
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||||
|
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ 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.common.TimelineScale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.storageValue
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
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 de.jeanlucmakiola.calendula.widget.WidgetSize
|
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||||
@@ -270,6 +273,19 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name }
|
store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How tall an hour is drawn in the week and day timelines (#56). Defaults to
|
||||||
|
* [TimelineScale.Regular] — the historical 56dp scale. Holds either a preset
|
||||||
|
* or the height a pinch on the timeline settled at.
|
||||||
|
*/
|
||||||
|
val timelineScale: Flow<TimelineScale> = store.data.map { prefs ->
|
||||||
|
parseTimelineScale(prefs[TIMELINE_SCALE_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setTimelineScale(scale: TimelineScale) {
|
||||||
|
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the jump-to-today control lives (issue #60). Default OFF — the
|
* 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 "+"
|
* historical layout, where it's an extended FAB that fades in above the "+"
|
||||||
@@ -285,6 +301,19 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[TODAY_BUTTON_IN_TOOLBAR_KEY] = enabled }
|
store.edit { it[TODAY_BUTTON_IN_TOOLBAR_KEY] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether events can be dragged to another slot in the calendar views (#68,
|
||||||
|
* #173). Defaults to ON. Off means no block registers a drag gesture at all;
|
||||||
|
* rescheduling then goes through the edit form.
|
||||||
|
*/
|
||||||
|
val dragToReschedule: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[DRAG_TO_RESCHEDULE_KEY] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDragToReschedule(enabled: Boolean) {
|
||||||
|
store.edit { it[DRAG_TO_RESCHEDULE_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
* 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
|
||||||
@@ -457,6 +486,46 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled }
|
store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a new **timed** event lasts, in minutes (#54). Defaults to
|
||||||
|
* [DEFAULT_EVENT_DURATION] — the historical fixed hour. Per-calendar
|
||||||
|
* overrides in [perCalendarEventDuration] take precedence; all-day events are
|
||||||
|
* date-anchored and ignore it. Resolve with [resolveDefaultEventDuration].
|
||||||
|
*/
|
||||||
|
val defaultEventDurationMinutes: Flow<Int> = store.data.map { prefs ->
|
||||||
|
(prefs[DEFAULT_EVENT_DURATION_KEY] ?: DEFAULT_EVENT_DURATION)
|
||||||
|
.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDefaultEventDurationMinutes(minutes: Int) {
|
||||||
|
store.edit {
|
||||||
|
it[DEFAULT_EVENT_DURATION_KEY] = minutes.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-calendar overrides of [defaultEventDurationMinutes], keyed by calendar
|
||||||
|
* id: a calendar **present** in the map gives its new events that length, one
|
||||||
|
* **absent** inherits the global default (there is no "no duration", so this
|
||||||
|
* needs no `none` sentinel). Serialised as `id=minutes;id=minutes`.
|
||||||
|
*/
|
||||||
|
val perCalendarEventDuration: Flow<Map<Long, Int>> = store.data.map { prefs ->
|
||||||
|
parseDurationOverrides(prefs[CALENDAR_EVENT_DURATION_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [minutes] null drops the override, so the calendar inherits the global default. */
|
||||||
|
suspend fun setCalendarEventDuration(calendarId: Long, minutes: Int?) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val current = parseDurationOverrides(prefs[CALENDAR_EVENT_DURATION_KEY]).toMutableMap()
|
||||||
|
if (minutes == null) {
|
||||||
|
current.remove(calendarId)
|
||||||
|
} else {
|
||||||
|
current[calendarId] = minutes.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
|
||||||
|
}
|
||||||
|
prefs[CALENDAR_EVENT_DURATION_KEY] = current.toStoredDurations()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
||||||
* for users whose only calendar app this is, reminders are essential; the
|
* for users whose only calendar app this is, reminders are essential; the
|
||||||
@@ -494,8 +563,87 @@ class SettingsPrefs @Inject constructor(
|
|||||||
prefs[REMINDER_ONBOARDING_KEY] ?: false
|
prefs[REMINDER_ONBOARDING_KEY] ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setReminderOnboardingDone() {
|
/** Cleared, not just set: the wizard's back button re-asks a step (#163). */
|
||||||
store.edit { it[REMINDER_ONBOARDING_KEY] = true }
|
suspend fun setReminderOnboardingDone(done: Boolean = true) {
|
||||||
|
store.edit { it[REMINDER_ONBOARDING_KEY] = done }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this install is a fresh one still owing the wizard's optional
|
||||||
|
* steps (#163). Armed on any launch that holds the calendar permission and
|
||||||
|
* has not finished the reminder step, and cleared again by
|
||||||
|
* [finishOnboardingWizard], so an existing user who revokes and re-grants
|
||||||
|
* the permission isn't re-onboarded.
|
||||||
|
*/
|
||||||
|
val onboardingWizardArmed: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[ONBOARDING_WIZARD_ARMED_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun armOnboardingWizard() {
|
||||||
|
store.edit { prefs ->
|
||||||
|
if (prefs[REMINDER_ONBOARDING_KEY] != true) prefs[ONBOARDING_WIZARD_ARMED_KEY] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the wizard's backup step has been answered (or skipped). */
|
||||||
|
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[ONBOARDING_BACKUP_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setOnboardingBackupDone(done: Boolean = true) {
|
||||||
|
store.edit { it[ONBOARDING_BACKUP_KEY] = done }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the wizard's default-view step has been answered (or skipped). */
|
||||||
|
val onboardingViewDone: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[ONBOARDING_VIEW_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setOnboardingViewDone(done: Boolean = true) {
|
||||||
|
store.edit { it[ONBOARDING_VIEW_KEY] = done }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the wizard's month-style step has been answered (or skipped). */
|
||||||
|
val onboardingMonthStyleDone: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[ONBOARDING_MONTH_STYLE_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setOnboardingMonthStyleDone(done: Boolean = true) {
|
||||||
|
store.edit { it[ONBOARDING_MONTH_STYLE_KEY] = done }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the wizard's calendar-visibility notice has been read. Separate
|
||||||
|
* from [CalendarPrefs.visibilityNoticePending], which says whether this
|
||||||
|
* install has anything to announce at all.
|
||||||
|
*/
|
||||||
|
val onboardingVisibilityDone: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[ONBOARDING_VISIBILITY_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setOnboardingVisibilityDone(done: Boolean = true) {
|
||||||
|
store.edit { it[ONBOARDING_VISIBILITY_KEY] = done }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the wizard's closing screen has been seen. */
|
||||||
|
val onboardingDoneShown: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[ONBOARDING_DONE_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setOnboardingDoneShown(shown: Boolean = true) {
|
||||||
|
store.edit { it[ONBOARDING_DONE_KEY] = shown }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the wizard for good: the closing screen has been seen and the
|
||||||
|
* install stops counting as fresh, so a later revoke-and-re-grant is only
|
||||||
|
* the permission screen. One edit, so the plan can't see it half-closed.
|
||||||
|
*/
|
||||||
|
suspend fun finishOnboardingWizard() {
|
||||||
|
store.edit { prefs ->
|
||||||
|
prefs[ONBOARDING_DONE_KEY] = true
|
||||||
|
prefs[ONBOARDING_WIZARD_ARMED_KEY] = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -808,14 +956,33 @@ class SettingsPrefs @Inject constructor(
|
|||||||
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 MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style")
|
||||||
|
internal val TIMELINE_SCALE_KEY = stringPreferencesKey("timeline_scale")
|
||||||
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
|
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
|
||||||
|
internal val DRAG_TO_RESCHEDULE_KEY = booleanPreferencesKey("drag_to_reschedule")
|
||||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
internal val 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")
|
||||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||||
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
||||||
|
internal val DEFAULT_EVENT_DURATION_KEY = intPreferencesKey("default_event_duration_minutes")
|
||||||
|
internal val CALENDAR_EVENT_DURATION_KEY =
|
||||||
|
stringPreferencesKey("per_calendar_event_duration")
|
||||||
|
|
||||||
|
/** A new timed event's length until the user changes it: one hour. */
|
||||||
|
const val DEFAULT_EVENT_DURATION = 60
|
||||||
|
internal const val MIN_EVENT_DURATION = 1
|
||||||
|
/** A day — past that the form is describing a multi-day event, not a default. */
|
||||||
|
internal const val MAX_EVENT_DURATION = 1_440
|
||||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
|
internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
|
||||||
|
internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
|
||||||
|
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
|
||||||
|
internal val ONBOARDING_MONTH_STYLE_KEY =
|
||||||
|
booleanPreferencesKey("onboarding_month_style_done")
|
||||||
|
internal val ONBOARDING_VISIBILITY_KEY =
|
||||||
|
booleanPreferencesKey("onboarding_visibility_done")
|
||||||
|
internal val ONBOARDING_DONE_KEY = booleanPreferencesKey("onboarding_done_shown")
|
||||||
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||||
booleanPreferencesKey("allow_color_unsupported_calendars")
|
booleanPreferencesKey("allow_color_unsupported_calendars")
|
||||||
internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes")
|
internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes")
|
||||||
@@ -905,6 +1072,17 @@ fun resolveDefaultReminder(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The length a new timed event on [calendarId] opens with: that calendar's
|
||||||
|
* override if it has one, otherwise the [global] default. Pure so it can be
|
||||||
|
* unit-tested.
|
||||||
|
*/
|
||||||
|
fun resolveDefaultEventDuration(
|
||||||
|
global: Int,
|
||||||
|
overrides: Map<Long, Int>,
|
||||||
|
calendarId: Long?,
|
||||||
|
): Int = calendarId?.let { overrides[it] } ?: global
|
||||||
|
|
||||||
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
||||||
private const val WEEK_START_AUTO = "AUTO"
|
private const val WEEK_START_AUTO = "AUTO"
|
||||||
|
|
||||||
@@ -952,6 +1130,27 @@ private fun String?.toReminderList(): List<Int> = when {
|
|||||||
private fun List<Int>.toStoredReminders(): String =
|
private fun List<Int>.toStoredReminders(): String =
|
||||||
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the per-calendar duration map (`id=minutes` entries joined by `;`).
|
||||||
|
* Malformed entries and out-of-range lengths are dropped, so a garbled value
|
||||||
|
* degrades to "inherits the global default" instead of throwing.
|
||||||
|
*/
|
||||||
|
private fun parseDurationOverrides(stored: String?): Map<Long, Int> =
|
||||||
|
stored?.split(ENTRY_SEP).orEmpty().mapNotNull { entry ->
|
||||||
|
val parts = entry.split(KEY_VALUE_SEP)
|
||||||
|
if (parts.size != 2) return@mapNotNull null
|
||||||
|
val calendarId = parts[0].trim().toLongOrNull() ?: return@mapNotNull null
|
||||||
|
val minutes = parts[1].trim().toIntOrNull()
|
||||||
|
?.takeIf { it in SettingsPrefs.MIN_EVENT_DURATION..SettingsPrefs.MAX_EVENT_DURATION }
|
||||||
|
?: return@mapNotNull null
|
||||||
|
calendarId to minutes
|
||||||
|
}.toMap()
|
||||||
|
|
||||||
|
private fun Map<Long, Int>.toStoredDurations(): String =
|
||||||
|
entries.sortedBy { it.key }.joinToString(ENTRY_SEP) { (id, minutes) ->
|
||||||
|
"$id$KEY_VALUE_SEP$minutes"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ val CalendarSource.hasVisibilitySwitch: Boolean
|
|||||||
val CalendarSource.isEventTarget: Boolean
|
val CalendarSource.isEventTarget: Boolean
|
||||||
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced
|
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this calendar's events may have their times rewritten by a drag (#68).
|
||||||
|
* Deliberately not [isEventTarget]: a managed event stays editable (reminders,
|
||||||
|
* notes) yet must never move, since the next contacts sync would put it back.
|
||||||
|
*/
|
||||||
|
val CalendarSource.allowsEventMove: Boolean
|
||||||
|
get() = canModifyContents && !isManaged
|
||||||
|
|
||||||
/** Every state worth naming on this calendar's row, in reading order. */
|
/** Every state worth naming on this calendar's row, in reading order. */
|
||||||
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
|
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
|
||||||
if (isManaged) add(CalendarStateLabel.MANAGED)
|
if (isManaged) add(CalendarStateLabel.MANAGED)
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
import kotlin.math.abs
|
import de.jeanlucmakiola.calendula.domain.color.Oklch
|
||||||
import kotlin.math.atan2
|
import de.jeanlucmakiola.calendula.domain.color.eventTone
|
||||||
import kotlin.math.cbrt
|
import de.jeanlucmakiola.calendula.domain.color.oklchOf
|
||||||
import kotlin.math.hypot
|
|
||||||
import kotlin.math.pow
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
import kotlin.math.sqrt
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Curates an account's published event palette for the colour picker.
|
* Curates an account's published event palette for the colour picker.
|
||||||
@@ -18,24 +14,25 @@ import kotlin.math.sqrt
|
|||||||
* (#22).
|
* (#22).
|
||||||
*
|
*
|
||||||
* Crucially, curation runs against the colour the picker actually *paints*, not
|
* Crucially, curation runs against the colour the picker actually *paints*, not
|
||||||
* the raw provider value. The picker softens every swatch through [pastelArgb]:
|
* the raw provider value — and it gets that colour from the same [eventTone]
|
||||||
* it pins lightness to a constant and caps saturation, so the raw palette's
|
* the picker calls, rather than from a copy of its shaping kept in step by hand.
|
||||||
* lightness axis is invisible on screen. Two raw colours that look different —
|
* Because a harmonised container pins lightness, the raw palette's lightness
|
||||||
* a navy and a mid blue — paint as one swatch, and every neutral (black, the
|
* axis is invisible on screen: two raw colours that look different — a navy and
|
||||||
* grays, white) paints as the same pale tint. Judging distinctness in raw
|
* a mid blue — paint as one swatch, and every neutral (black, the grays, white)
|
||||||
* space, as before, left near-identical painted swatches and stranded the
|
* paints as the same grey. Judging distinctness in raw space, as before, left
|
||||||
* neutrals as a run of look-alike "pinks" at the end of the grid.
|
* near-identical painted swatches and stranded the neutrals as a run of
|
||||||
|
* look-alike tints at the end of the grid.
|
||||||
*
|
*
|
||||||
* Three steps, all in painted space:
|
* Three steps, all in painted space:
|
||||||
* 1. Collapse swatches that paint identically to one (alphabetically-first key
|
* 1. Collapse swatches that paint identically to one (alphabetically-first key
|
||||||
* wins, deterministically) — this folds aliases, dark/light shades of a
|
* wins, deterministically) — this folds aliases, dark/light shades of a
|
||||||
* hue, and all the neutrals together.
|
* hue, and all the neutrals together.
|
||||||
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the washed-out
|
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the neutral-origin
|
||||||
* neutral-origin tints (painted chroma < [PASTEL_CHROMA_FLOOR]) and are
|
* swatches — with lightness pinned, a grey source paints as plain grey, so
|
||||||
* then thinned to visually distinct colours: most vivid first, a colour is
|
* "has no hue left" is simply zero chroma — and are then thinned to visually
|
||||||
* kept only when at least [MIN_DELTA_E] (CIE76, painted Lab) from every
|
* distinct colours: most vivid first, a colour is kept only when at least
|
||||||
* colour already kept. Small palettes are already curated by their adapter
|
* [MIN_DISTANCE] away in Oklab from every colour already kept. Small
|
||||||
* and pass through whole.
|
* palettes are already curated by their adapter and pass through whole.
|
||||||
* 3. The survivors are ordered like a rainbow — continuously by painted hue —
|
* 3. The survivors are ordered like a rainbow — continuously by painted hue —
|
||||||
* with the wheel cut at its single widest empty gap so the one unavoidable
|
* with the wheel cut at its single widest empty gap so the one unavoidable
|
||||||
* seam lands in dead space and no hue family is torn across both ends.
|
* seam lands in dead space and no hue family is torn across both ends.
|
||||||
@@ -45,12 +42,12 @@ import kotlin.math.sqrt
|
|||||||
*/
|
*/
|
||||||
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
||||||
val painted = sortedBy { it.key }
|
val painted = sortedBy { it.key }
|
||||||
.distinctBy { pastelArgb(it.argb) }
|
.distinctBy { paintedArgb(it.argb) }
|
||||||
.map { it to Lab.of(pastelArgb(it.argb)) }
|
.map { it to oklchOf(paintedArgb(it.argb)) }
|
||||||
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
|
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
|
||||||
painted
|
painted
|
||||||
} else {
|
} else {
|
||||||
thin(painted.filter { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR })
|
thin(painted.filter { (_, painted) -> painted.chroma > 0f })
|
||||||
}
|
}
|
||||||
return orderAroundWheel(kept).map { (option, _) -> option }
|
return orderAroundWheel(kept).map { (option, _) -> option }
|
||||||
}
|
}
|
||||||
@@ -61,17 +58,17 @@ fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
|||||||
* instead of mid-family. Saturation breaks ties, vivid first.
|
* instead of mid-family. Saturation breaks ties, vivid first.
|
||||||
*/
|
*/
|
||||||
private fun orderAroundWheel(
|
private fun orderAroundWheel(
|
||||||
swatches: List<Pair<EventColorOption, Lab>>,
|
swatches: List<Pair<EventColorOption, Oklch>>,
|
||||||
): List<Pair<EventColorOption, Lab>> {
|
): List<Pair<EventColorOption, Oklch>> {
|
||||||
if (swatches.size < 2) return swatches
|
if (swatches.size < 2) return swatches
|
||||||
val byHue = swatches.sortedWith(
|
val byHue = swatches.sortedWith(
|
||||||
compareBy({ (_, lab) -> lab.hue }, { (_, lab) -> -lab.chroma }),
|
compareBy({ (_, painted) -> painted.hue }, { (_, painted) -> -painted.chroma }),
|
||||||
)
|
)
|
||||||
// Split the wheel after the largest empty arc between neighbouring hues;
|
// Split the wheel after the largest empty arc between neighbouring hues;
|
||||||
// the default is the wrap gap (last hue back round to the first), i.e. the
|
// the default is the wrap gap (last hue back round to the first), i.e. the
|
||||||
// familiar 0→360 order, and we only rotate away from it for a wider void.
|
// familiar 0→360 order, and we only rotate away from it for a wider void.
|
||||||
var cutAfter = byHue.lastIndex
|
var cutAfter = byHue.lastIndex
|
||||||
var widestGap = 360.0 - byHue.last().second.hue + byHue.first().second.hue
|
var widestGap = 360f - byHue.last().second.hue + byHue.first().second.hue
|
||||||
for (i in 0 until byHue.lastIndex) {
|
for (i in 0 until byHue.lastIndex) {
|
||||||
val gap = byHue[i + 1].second.hue - byHue[i].second.hue
|
val gap = byHue[i + 1].second.hue - byHue[i].second.hue
|
||||||
if (gap > widestGap) {
|
if (gap > widestGap) {
|
||||||
@@ -84,102 +81,32 @@ private fun orderAroundWheel(
|
|||||||
|
|
||||||
/** Greedy max-distance filter: vivid colours stake out clusters first. */
|
/** Greedy max-distance filter: vivid colours stake out clusters first. */
|
||||||
private fun thin(
|
private fun thin(
|
||||||
swatches: List<Pair<EventColorOption, Lab>>,
|
swatches: List<Pair<EventColorOption, Oklch>>,
|
||||||
): List<Pair<EventColorOption, Lab>> {
|
): List<Pair<EventColorOption, Oklch>> {
|
||||||
val byVividness = swatches
|
val byVividness = swatches
|
||||||
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
|
.sortedWith(
|
||||||
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
|
compareByDescending<Pair<EventColorOption, Oklch>> { it.second.chroma }
|
||||||
|
.thenBy { it.first.key },
|
||||||
|
)
|
||||||
|
val kept = mutableListOf<Pair<EventColorOption, Oklch>>()
|
||||||
for (candidate in byVividness) {
|
for (candidate in byVividness) {
|
||||||
if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate
|
if (kept.none { it.second.distanceTo(candidate.second) < MIN_DISTANCE }) kept += candidate
|
||||||
}
|
}
|
||||||
return kept
|
return kept
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The colour the picker paints for [argb]; the light theme stands in as the
|
||||||
* The softening the colour picker paints over every swatch: keep the hue, scale
|
* reference, since a harmonised container differs only in lightness by theme
|
||||||
* and clamp saturation into a gentle band, and pin value to a constant so
|
* and curation compares hue and chroma. */
|
||||||
* nothing screams and everything reads on the surface. Value is fixed here so
|
private fun paintedArgb(argb: Int): Int =
|
||||||
* curation is theme-independent — only hue and saturation distinguish painted
|
eventTone(argb, dark = false, harmonise = true).container
|
||||||
* swatches.
|
|
||||||
*
|
|
||||||
* This is a self-contained mirror of floret-kit's `pastelize` hue/saturation
|
|
||||||
* shaping (`de.jeanlucmakiola.floret.components.pastelize`), with value pinned
|
|
||||||
* rather than theme-picked. Curation must reason about the colour the picker
|
|
||||||
* paints, so the two shapings have to agree: if floret's saturation band or
|
|
||||||
* curve changes, update this in step.
|
|
||||||
*/
|
|
||||||
fun pastelArgb(rawArgb: Int): Int {
|
|
||||||
val r = ((rawArgb shr 16) and 0xFF) / 255f
|
|
||||||
val g = ((rawArgb shr 8) and 0xFF) / 255f
|
|
||||||
val b = (rawArgb and 0xFF) / 255f
|
|
||||||
val max = maxOf(r, g, b)
|
|
||||||
val min = minOf(r, g, b)
|
|
||||||
val delta = max - min
|
|
||||||
val hue = when {
|
|
||||||
delta == 0f -> 0f
|
|
||||||
max == r -> 60f * (((g - b) / delta) % 6f)
|
|
||||||
max == g -> 60f * (((b - r) / delta) + 2f)
|
|
||||||
else -> 60f * (((r - g) / delta) + 4f)
|
|
||||||
}.let { if (it < 0f) it + 360f else it }
|
|
||||||
val sat = (if (max == 0f) 0f else delta / max) * 0.6f
|
|
||||||
val s = sat.coerceIn(0.25f, 0.65f)
|
|
||||||
val v = PASTEL_VALUE
|
|
||||||
val c = v * s
|
|
||||||
val x = c * (1f - abs((hue / 60f) % 2f - 1f))
|
|
||||||
val m = v - c
|
|
||||||
val (rr, gg, bb) = when {
|
|
||||||
hue < 60f -> Triple(c, x, 0f)
|
|
||||||
hue < 120f -> Triple(x, c, 0f)
|
|
||||||
hue < 180f -> Triple(0f, c, x)
|
|
||||||
hue < 240f -> Triple(0f, x, c)
|
|
||||||
hue < 300f -> Triple(x, 0f, c)
|
|
||||||
else -> Triple(c, 0f, x)
|
|
||||||
}
|
|
||||||
fun channel(value: Float) = ((value + m) * 255f).roundToInt().coerceIn(0, 255)
|
|
||||||
return (0xFF shl 24) or (channel(rr) shl 16) or (channel(gg) shl 8) or channel(bb)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reference lightness for curation; the picker paints at this on dark surfaces. */
|
|
||||||
private const val PASTEL_VALUE = 0.82f
|
|
||||||
|
|
||||||
/** Palettes at most this big skip the thinning (Google's ~26 pass through). */
|
/** Palettes at most this big skip the thinning (Google's ~26 pass through). */
|
||||||
private const val CURATION_TRIGGER_SIZE = 36
|
private const val CURATION_TRIGGER_SIZE = 36
|
||||||
|
|
||||||
/** Minimum CIE76 ΔE between surviving painted swatches. */
|
|
||||||
private const val MIN_DELTA_E = 13.0
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Painted-chroma floor for oversized palettes: below this a swatch is a washed-
|
* Minimum Oklab distance between surviving painted swatches. Painted colours all
|
||||||
* out tint — the neutrals and near-whites the saturation clamp muddies — so it
|
* share one lightness, so this is really a hue/chroma separation — far enough
|
||||||
* is dropped rather than shown as pale filler.
|
* apart that two swatches never read as the same colour in the grid.
|
||||||
*/
|
*/
|
||||||
private const val PASTEL_CHROMA_FLOOR = 22.0
|
private const val MIN_DISTANCE = 0.025f
|
||||||
|
|
||||||
/** CIE Lab (D65) — the space where Euclidean distance ≈ perceived difference. */
|
|
||||||
private class Lab(val l: Double, val a: Double, val b: Double) {
|
|
||||||
val chroma: Double get() = hypot(a, b)
|
|
||||||
|
|
||||||
/** Hue angle in degrees, 0–360, around the Lab a-b plane. */
|
|
||||||
val hue: Double get() = (Math.toDegrees(atan2(b, a)) + 360.0) % 360.0
|
|
||||||
|
|
||||||
fun deltaE(other: Lab): Double =
|
|
||||||
sqrt((l - other.l).pow(2) + (a - other.a).pow(2) + (b - other.b).pow(2))
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun of(argb: Int): Lab {
|
|
||||||
fun linear(shift: Int): Double {
|
|
||||||
val c = ((argb shr shift) and 0xFF) / 255.0
|
|
||||||
return if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
|
|
||||||
}
|
|
||||||
val r = linear(16)
|
|
||||||
val g = linear(8)
|
|
||||||
val b = linear(0)
|
|
||||||
val x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
|
|
||||||
val y = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
||||||
val z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
|
|
||||||
fun f(t: Double) = if (t > 0.008856) cbrt(t) else 7.787 * t + 16.0 / 116.0
|
|
||||||
val fy = f(y)
|
|
||||||
return Lab(116 * fy - 16, 500 * (f(x) - fy), 200 * (fy - f(z)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.number
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** An event with the free text the search may have matched it on. */
|
||||||
|
data class SearchCandidate(
|
||||||
|
val event: EventInstance,
|
||||||
|
val description: String? = null,
|
||||||
|
/** Null when the event has none: [EventInstance.title] is then a display
|
||||||
|
* placeholder, which the query must not catch on. */
|
||||||
|
val title: String? = event.title,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A matched run inside one of a hit's texts, for highlighting. */
|
||||||
|
data class MatchSpan(val start: Int, val end: Int)
|
||||||
|
|
||||||
|
/** The part of a description a token matched, elided to what fits a row. */
|
||||||
|
data class DescriptionSnippet(
|
||||||
|
val text: String,
|
||||||
|
val spans: List<MatchSpan>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One result: the event, plus where the query matched it. */
|
||||||
|
data class SearchHit(
|
||||||
|
val event: EventInstance,
|
||||||
|
val titleSpans: List<MatchSpan> = emptyList(),
|
||||||
|
val locationSpans: List<MatchSpan> = emptyList(),
|
||||||
|
val descriptionSnippet: DescriptionSnippet? = null,
|
||||||
|
/** Already over, so the row can say so. Finished-today counts. */
|
||||||
|
val isPast: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One month's worth of hits, as the results list draws them under a header. */
|
||||||
|
data class SearchMonth(
|
||||||
|
val year: Int,
|
||||||
|
/** 1–12, so the UI can build the month's first day for a localized label. */
|
||||||
|
val monthNumber: Int,
|
||||||
|
val hits: List<SearchHit>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A finished search: a calendar of hits, plus the description-only ones. */
|
||||||
|
data class SearchResults(
|
||||||
|
val months: List<SearchMonth>,
|
||||||
|
val inDescriptions: List<SearchHit>,
|
||||||
|
) {
|
||||||
|
val isEmpty: Boolean get() = months.isEmpty() && inDescriptions.isEmpty()
|
||||||
|
|
||||||
|
/** Every hit, whichever section it sits in — for select-all and delete. */
|
||||||
|
val allHits: List<SearchHit> get() = months.flatMap { it.hits } + inDescriptions
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turning a typed query into the results list.
|
||||||
|
*
|
||||||
|
* An event has to carry *every* whitespace-separated token, each in any of
|
||||||
|
* title / location / description; case is folded in Kotlin rather than by SQL's
|
||||||
|
* ASCII-only `LIKE`, so "ärzte" finds "Ärzte".
|
||||||
|
*
|
||||||
|
* Results are a calendar, not a ranking: months from the current one, then
|
||||||
|
* forwards, then backwards, and inside a month what is still to come before
|
||||||
|
* what has passed. The day is the boundary, not the moment. A hit the query
|
||||||
|
* only reached through its description is held back in
|
||||||
|
* [SearchResults.inDescriptions] rather than dated among the real ones.
|
||||||
|
*/
|
||||||
|
object EventSearch {
|
||||||
|
|
||||||
|
/** Characters of description kept around the match in a row's snippet. */
|
||||||
|
private const val SNIPPET_LENGTH = 96
|
||||||
|
|
||||||
|
/** Characters of lead-in shown before the match, when there is room. */
|
||||||
|
private const val SNIPPET_LEAD = 24
|
||||||
|
|
||||||
|
private val WHITESPACE = Regex("\\s+")
|
||||||
|
|
||||||
|
/** Shared with the data layer so its SQL pre-filter tokenises identically. */
|
||||||
|
fun tokenize(query: String): List<String> =
|
||||||
|
query.trim().split(WHITESPACE).filter { it.isNotEmpty() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search [candidates] for [query], dropping any that don't carry every token.
|
||||||
|
* [now] marks a hit as over, [todayStart] is where it stops being current.
|
||||||
|
* [zone] places each event on the calendar through the span rule, so an
|
||||||
|
* all-day event keeps its own day whichever side of UTC we are on (#82).
|
||||||
|
*/
|
||||||
|
fun search(
|
||||||
|
candidates: List<SearchCandidate>,
|
||||||
|
query: String,
|
||||||
|
now: Instant,
|
||||||
|
todayStart: Instant,
|
||||||
|
zone: TimeZone,
|
||||||
|
): SearchResults {
|
||||||
|
val tokens = tokenize(query)
|
||||||
|
if (tokens.isEmpty()) return SearchResults(emptyList(), emptyList())
|
||||||
|
|
||||||
|
val today = todayStart.toLocalDateTime(zone).date
|
||||||
|
val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now, zone) }
|
||||||
|
val upcoming = { m: Matched -> m.event.spanLastDay(zone) >= today }
|
||||||
|
val order = compareBy<Matched> { if (upcoming(it)) 0 else 1 }
|
||||||
|
.thenComparator { a, b ->
|
||||||
|
// What is left reads forwards; what is behind us reads backwards
|
||||||
|
// from today, nearest first.
|
||||||
|
if (upcoming(a)) {
|
||||||
|
a.event.dayStart(zone).compareTo(b.event.dayStart(zone))
|
||||||
|
} else {
|
||||||
|
b.event.dayStart(zone).compareTo(a.event.dayStart(zone))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val (inDescriptions, dated) = matched.sortedWith(order).partition { it.descriptionOnly }
|
||||||
|
return SearchResults(
|
||||||
|
months = groupByMonth(dated, zone, todayStart),
|
||||||
|
inDescriptions = inDescriptions.map { it.hit },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Months from the current one, then forwards, then backwards. */
|
||||||
|
private fun groupByMonth(
|
||||||
|
dated: List<Matched>,
|
||||||
|
zone: TimeZone,
|
||||||
|
todayStart: Instant,
|
||||||
|
): List<SearchMonth> {
|
||||||
|
val currentKey = monthKey(todayStart.toLocalDateTime(zone).date)
|
||||||
|
return dated
|
||||||
|
.groupBy { monthKey(it.event.spanFirstDay(zone)) }
|
||||||
|
.entries
|
||||||
|
.sortedWith(
|
||||||
|
compareBy<Map.Entry<Int, List<Matched>>> {
|
||||||
|
when {
|
||||||
|
it.key == currentKey -> 0
|
||||||
|
it.key > currentKey -> 1
|
||||||
|
else -> 2
|
||||||
|
}
|
||||||
|
}.thenComparator { a, b ->
|
||||||
|
if (a.key > currentKey) a.key.compareTo(b.key) else b.key.compareTo(a.key)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map { (key, entries) ->
|
||||||
|
SearchMonth(
|
||||||
|
year = key / 12,
|
||||||
|
monthNumber = key % 12 + 1,
|
||||||
|
hits = entries.map { it.hit },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Year and month as one comparable number. */
|
||||||
|
private fun monthKey(date: LocalDate): Int = date.year * 12 + (date.month.number - 1)
|
||||||
|
|
||||||
|
private fun match(
|
||||||
|
candidate: SearchCandidate,
|
||||||
|
tokens: List<String>,
|
||||||
|
now: Instant,
|
||||||
|
zone: TimeZone,
|
||||||
|
): Matched? {
|
||||||
|
val title = candidate.title?.takeIf { it.isNotBlank() }
|
||||||
|
val location = candidate.event.location?.takeIf { it.isNotBlank() }
|
||||||
|
// Collapsed so a multi-line description matches the way it is drawn.
|
||||||
|
val description = candidate.description
|
||||||
|
?.replace(WHITESPACE, " ")
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
// Mid-word counts: "termin" has to keep finding "Zahnarzttermin".
|
||||||
|
val present = { token: String ->
|
||||||
|
title?.contains(token, ignoreCase = true) == true ||
|
||||||
|
location?.contains(token, ignoreCase = true) == true ||
|
||||||
|
description?.contains(token, ignoreCase = true) == true
|
||||||
|
}
|
||||||
|
if (!tokens.all(present)) return null
|
||||||
|
|
||||||
|
val descriptionSpans = description?.let { spansIn(it, tokens) }.orEmpty()
|
||||||
|
val titleSpans = title?.let { spansIn(it, tokens) }.orEmpty()
|
||||||
|
val locationSpans = location?.let { spansIn(it, tokens) }.orEmpty()
|
||||||
|
return Matched(
|
||||||
|
hit = SearchHit(
|
||||||
|
event = candidate.event,
|
||||||
|
titleSpans = titleSpans,
|
||||||
|
locationSpans = locationSpans,
|
||||||
|
descriptionSnippet = description
|
||||||
|
?.takeIf { descriptionSpans.isNotEmpty() }
|
||||||
|
?.let { snippet(it, descriptionSpans) },
|
||||||
|
isPast = candidate.event.dayEnd(zone) < now,
|
||||||
|
),
|
||||||
|
// Nothing in the name or the place caught the query: it is only here
|
||||||
|
// for something in its notes.
|
||||||
|
descriptionOnly = titleSpans.isEmpty() && locationSpans.isEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every occurrence of every token in [text], merged where they overlap. */
|
||||||
|
private fun spansIn(text: String, tokens: List<String>): List<MatchSpan> {
|
||||||
|
val raw = mutableListOf<MatchSpan>()
|
||||||
|
for (token in tokens) {
|
||||||
|
var index = text.indexOf(token, startIndex = 0, ignoreCase = true)
|
||||||
|
while (index >= 0) {
|
||||||
|
raw += MatchSpan(index, index + token.length)
|
||||||
|
index = text.indexOf(token, startIndex = index + 1, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (raw.isEmpty()) return emptyList()
|
||||||
|
val sorted = raw.sortedBy { it.start }
|
||||||
|
val merged = mutableListOf(sorted.first())
|
||||||
|
for (span in sorted.drop(1)) {
|
||||||
|
val last = merged.last()
|
||||||
|
if (span.start <= last.end) {
|
||||||
|
merged[merged.lastIndex] = MatchSpan(last.start, maxOf(last.end, span.end))
|
||||||
|
} else {
|
||||||
|
merged += span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A window of [description] around its first match, elided at both ends,
|
||||||
|
* with the spans rebased onto it and any falling outside dropped.
|
||||||
|
*/
|
||||||
|
private fun snippet(description: String, spans: List<MatchSpan>): DescriptionSnippet {
|
||||||
|
if (description.length <= SNIPPET_LENGTH) {
|
||||||
|
return DescriptionSnippet(description, spans)
|
||||||
|
}
|
||||||
|
val first = spans.first().start
|
||||||
|
val start = (first - SNIPPET_LEAD).coerceIn(0, (description.length - SNIPPET_LENGTH))
|
||||||
|
val end = (start + SNIPPET_LENGTH).coerceAtMost(description.length)
|
||||||
|
val prefix = if (start > 0) "…" else ""
|
||||||
|
val suffix = if (end < description.length) "…" else ""
|
||||||
|
val text = prefix + description.substring(start, end) + suffix
|
||||||
|
val shift = prefix.length - start
|
||||||
|
val rebased = spans
|
||||||
|
.filter { it.start >= start && it.end <= end }
|
||||||
|
.map { MatchSpan(it.start + shift, it.end + shift) }
|
||||||
|
return DescriptionSnippet(text, rebased)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Matched(val hit: SearchHit, val descriptionOnly: Boolean) {
|
||||||
|
val event: EventInstance get() = hit.event
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event's first day as an instant in [zone]. An all-day event sits at
|
||||||
|
* UTC midnight, so its raw start would rank it on a day it isn't drawn on.
|
||||||
|
*/
|
||||||
|
private fun EventInstance.dayStart(zone: TimeZone): Instant =
|
||||||
|
if (isAllDay) spanFirstDay(zone).atStartOfDayIn(zone) else start
|
||||||
|
|
||||||
|
/** The moment the event's last day is over in [zone], exclusive. */
|
||||||
|
private fun EventInstance.dayEnd(zone: TimeZone): Instant =
|
||||||
|
if (isAllDay) spanLastDay(zone).plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone) else end
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zone this form's wall-clock times mean, matching what the data layer
|
||||||
|
* resolves them in at write time: the form's own pinned zone, else [deviceZone].
|
||||||
|
*/
|
||||||
|
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
|
||||||
|
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form moved so it starts at [newStart], keeping its **instant** duration —
|
||||||
|
* a recurring event's length travels to the provider as `DURATION`, so keeping
|
||||||
|
* wall clock instead would rewrite the series' length across a DST boundary.
|
||||||
|
*
|
||||||
|
* All-day events are date-anchored — use [shiftedByDays]; this returns them
|
||||||
|
* untouched.
|
||||||
|
*/
|
||||||
|
fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm {
|
||||||
|
if (isAllDay) return this
|
||||||
|
val zone = resolvedZone(deviceZone)
|
||||||
|
val span = end.toInstant(zone) - start.toInstant(zone)
|
||||||
|
return copy(
|
||||||
|
start = newStart.toLocalDateTime(zone),
|
||||||
|
end = (newStart + span).toLocalDateTime(zone),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form moved [days] calendar days, keeping its time of day and its span.
|
||||||
|
* All-day events are pure date arithmetic; a timed one preserves its **instant**
|
||||||
|
* duration, for the same reason [shiftedTo] does.
|
||||||
|
*/
|
||||||
|
fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
|
||||||
|
if (days == 0) return this
|
||||||
|
val newStart = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time)
|
||||||
|
if (isAllDay) {
|
||||||
|
return copy(
|
||||||
|
start = newStart,
|
||||||
|
end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val zone = resolvedZone(deviceZone)
|
||||||
|
val span = end.toInstant(zone) - start.toInstant(zone)
|
||||||
|
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone))
|
||||||
|
}
|
||||||
@@ -15,7 +15,9 @@ import kotlin.time.Instant
|
|||||||
* calendar this way to create a new event, passing the fields as
|
* calendar this way to create a new event, passing the fields as
|
||||||
* [android.provider.CalendarContract] extras. Any field the intent omits falls
|
* [android.provider.CalendarContract] extras. Any field the intent omits falls
|
||||||
* back to the same defaults the in-app "new event" uses — a timed start at the
|
* back to the same defaults the in-app "new event" uses — a timed start at the
|
||||||
* next full hour and a one-hour duration. [EventForm.calendarId] is left null so
|
* next full hour, and a placeholder hour for a missing end that the form then
|
||||||
|
* stretches to the default-duration setting (the intent is opened as
|
||||||
|
* `ImportSource.InsertOpenEnded`, #54). [EventForm.calendarId] is left null so
|
||||||
* it resolves to the last-used / first-writable calendar, exactly like the
|
* it resolves to the last-used / first-writable calendar, exactly like the
|
||||||
* `.ics` single-event and plain new-event paths.
|
* `.ics` single-event and plain new-event paths.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ data class EventInstance(
|
|||||||
val isAllDay: Boolean,
|
val isAllDay: Boolean,
|
||||||
val color: Int,
|
val color: Int,
|
||||||
val location: String?,
|
val location: String?,
|
||||||
|
/**
|
||||||
|
* Only search results, which read the series master, fill this in — the
|
||||||
|
* Instances query already yields one row per occurrence.
|
||||||
|
*/
|
||||||
|
val isRecurring: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,6 +129,12 @@ data class EventDetail(
|
|||||||
val eventColor: Int? = null,
|
val eventColor: Int? = null,
|
||||||
/** The event's `Events.EVENT_COLOR_KEY` (a calendar-palette key), or null. */
|
/** The event's `Events.EVENT_COLOR_KEY` (a calendar-palette key), or null. */
|
||||||
val eventColorKey: String? = null,
|
val eventColorKey: String? = null,
|
||||||
|
/**
|
||||||
|
* True when this row is a modified occurrence of a series (`ORIGINAL_ID` is
|
||||||
|
* set) rather than a master, so a reschedule takes the plain whole-row path
|
||||||
|
* whatever [rrule] a sync adapter left on it.
|
||||||
|
*/
|
||||||
|
val isException: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
internal val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
||||||
DayOfWeek.MONDAY to "MO",
|
DayOfWeek.MONDAY to "MO",
|
||||||
DayOfWeek.TUESDAY to "TU",
|
DayOfWeek.TUESDAY to "TU",
|
||||||
DayOfWeek.WEDNESDAY to "WE",
|
DayOfWeek.WEDNESDAY to "WE",
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [rrule] re-anchored from an occurrence on [oldStart] to one on [newStart].
|
||||||
|
*
|
||||||
|
* `Events.RRULE` is written verbatim while DTSTART moves, so `FREQ=WEEKLY;BYDAY=MO`
|
||||||
|
* would keep naming Monday after the anchor became a Wednesday and the series
|
||||||
|
* would not move at all — `BYDAY` is re-derived from [newStart].
|
||||||
|
*
|
||||||
|
* Only weekly `BYDAY` is realigned, and only for a whole-day move: the rule has
|
||||||
|
* to agree with the series *anchor*, and weekday arithmetic is the only kind
|
||||||
|
* that survives the same wall-clock shift unchanged. Everything else returns
|
||||||
|
* null, as do rules one moved occurrence can't resolve (`BYDAY=MO,WE`, `2TH`,
|
||||||
|
* `BYSETPOS`). The accepted parts are a subset of [parseSimpleRecurrence], so
|
||||||
|
* anything realignable is also a rule [problems] can check the `UNTIL` of.
|
||||||
|
*/
|
||||||
|
fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? {
|
||||||
|
if (oldStart == newStart) return rrule
|
||||||
|
val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else ""
|
||||||
|
val parts = rrule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }
|
||||||
|
if (parts.isEmpty()) return null
|
||||||
|
var weekly = false
|
||||||
|
val rebuilt = parts.map { part ->
|
||||||
|
val eq = part.indexOf('=')
|
||||||
|
if (eq <= 0) return null
|
||||||
|
val key = part.substring(0, eq).uppercase()
|
||||||
|
val value = part.substring(eq + 1).trim()
|
||||||
|
when (key) {
|
||||||
|
"FREQ" -> {
|
||||||
|
weekly = value.equals("WEEKLY", ignoreCase = true)
|
||||||
|
part
|
||||||
|
}
|
||||||
|
"BYDAY" -> {
|
||||||
|
val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null
|
||||||
|
if (!value.equals(old, ignoreCase = true)) return null
|
||||||
|
"BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}"
|
||||||
|
}
|
||||||
|
"INTERVAL", "COUNT", "UNTIL", "WKST" -> part
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// BYDAY is only simple on a weekly rule, matching parseSimpleRecurrence.
|
||||||
|
if (!weekly && parts.any { it.substringBefore('=').trim().uppercase() == "BYDAY" }) return null
|
||||||
|
if (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null
|
||||||
|
return prefix + rebuilt.joinToString(";")
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colours one calendar's identity resolves to in one theme.
|
||||||
|
*
|
||||||
|
* The app paints a calendar's colour in two structurally different places, and
|
||||||
|
* they want opposite things:
|
||||||
|
*
|
||||||
|
* - a **[container]** sits behind text (week and day blocks, month bars, widget
|
||||||
|
* rows, picker swatches), so it has to contrast with its own ink;
|
||||||
|
* - an **[accent]** is a mark on an ordinary app surface (day dots, agenda and
|
||||||
|
* search stripes, calendar icon tints, the detail header), so it has to
|
||||||
|
* contrast with the *surface* instead.
|
||||||
|
*
|
||||||
|
* Painting both from one colour is what made this hard to get right: deep enough
|
||||||
|
* to carry white text is too dark to see as a dot on a dark surface, and light
|
||||||
|
* enough to show up there is too pale to carry white text. Splitting them lets
|
||||||
|
* each be pinned to the lightness its job needs, while hue and chroma — the parts
|
||||||
|
* that actually say *which calendar this is* — stay shared, so the two roles
|
||||||
|
* still read as the same colour.
|
||||||
|
*/
|
||||||
|
data class EventTone(
|
||||||
|
val container: Int,
|
||||||
|
val onContainer: Int,
|
||||||
|
val accent: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve [rawArgb] for the current theme.
|
||||||
|
*
|
||||||
|
* With [harmonise] on, hue and chroma are kept and lightness is re-pinned per
|
||||||
|
* role, which is what makes the ink predictable: every container lands at the
|
||||||
|
* same perceptual lightness, so one ink colour serves every hue at ≥ 6:1. With
|
||||||
|
* it off the provider's colour is painted verbatim — the sync source's own look,
|
||||||
|
* as DAVx5/CalDAV users expect — and the ink is then chosen per colour, since
|
||||||
|
* nothing constrains what the provider sends.
|
||||||
|
*/
|
||||||
|
fun eventTone(rawArgb: Int, dark: Boolean, harmonise: Boolean): EventTone {
|
||||||
|
val opaque = rawArgb or 0xFF000000.toInt()
|
||||||
|
val raw = oklchOf(opaque)
|
||||||
|
if (!harmonise) {
|
||||||
|
return EventTone(
|
||||||
|
container = opaque,
|
||||||
|
onContainer = inkFor(raw.lightness),
|
||||||
|
accent = opaque,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// A near-grey source has no hue worth keeping, so it stays grey rather than
|
||||||
|
// being pushed to an arbitrary one; everything else is held inside a band
|
||||||
|
// that keeps calendars apart without going neon.
|
||||||
|
val chroma = if (raw.chroma < GREY_CHROMA) 0f else raw.chroma.coerceIn(MIN_CHROMA, MAX_CHROMA)
|
||||||
|
// Two poles rather than one. Forcing every hue deep is what turned the warm,
|
||||||
|
// naturally light ones muddy — a dark orange is brown and a dark yellow is
|
||||||
|
// olive, which is a fact about those hues, not a tuning miss. So a colour is
|
||||||
|
// sent to whichever pole it already sits nearer: most land deep and carry
|
||||||
|
// white ink, while genuinely light sources (yellows, creams, pale tints)
|
||||||
|
// stay light and carry dark ink, keeping the character that made them
|
||||||
|
// recognisable. Either way the colour is pulled clear of the middle, where
|
||||||
|
// neither ink reads well — which was the original fault.
|
||||||
|
val staysLight = raw.lightness >= LIGHT_POLE_THRESHOLD
|
||||||
|
val containerLightness = when {
|
||||||
|
staysLight && dark -> LIGHT_CONTAINER_LIGHTNESS_DARK
|
||||||
|
staysLight -> LIGHT_CONTAINER_LIGHTNESS_LIGHT
|
||||||
|
dark -> CONTAINER_LIGHTNESS_DARK
|
||||||
|
else -> CONTAINER_LIGHTNESS_LIGHT
|
||||||
|
}
|
||||||
|
// The accent takes no pole: it is a mark on the app's surface, so it has to
|
||||||
|
// contrast with that surface whichever way its container went.
|
||||||
|
val accentLightness = if (dark) ACCENT_LIGHTNESS_DARK else ACCENT_LIGHTNESS_LIGHT
|
||||||
|
return EventTone(
|
||||||
|
container = Oklch(containerLightness, chroma, raw.hue).toArgb(),
|
||||||
|
onContainer = inkFor(containerLightness),
|
||||||
|
accent = Oklch(accentLightness, chroma, raw.hue).toArgb(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* White or black ink for a background of the given perceptual [lightness].
|
||||||
|
*
|
||||||
|
* Derived rather than hardcoded so it stays right for raw provider colours,
|
||||||
|
* where lightness is whatever the sync source sent. For harmonised containers it
|
||||||
|
* is constant by construction — that is the point of pinning the lightness.
|
||||||
|
*/
|
||||||
|
fun inkFor(lightness: Float): Int =
|
||||||
|
if (lightness < INK_FLIP_LIGHTNESS) 0xFFFFFFFF.toInt() else 0xFF000000.toInt()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightness at which white ink overtakes black. Sits above the midpoint because
|
||||||
|
* lightness is perceptual: a colour has to be distinctly light before black wins.
|
||||||
|
*/
|
||||||
|
const val INK_FLIP_LIGHTNESS = 0.62f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw lightness at or above which a colour keeps its light character instead of
|
||||||
|
* being pushed deep. Set high enough that oranges and warm reds still go deep —
|
||||||
|
* a burnt orange reads as orange, where a dark yellow does not read as yellow —
|
||||||
|
* so only the genuinely pale sources take the light pole.
|
||||||
|
*/
|
||||||
|
const val LIGHT_POLE_THRESHOLD = 0.72f
|
||||||
|
|
||||||
|
/** Container lightness: deep enough that white ink clears 5.5:1 on every hue. */
|
||||||
|
const val CONTAINER_LIGHTNESS_LIGHT = 0.45f
|
||||||
|
|
||||||
|
/** Same in dark mode, a shade lighter so a block separates from the surface. */
|
||||||
|
const val CONTAINER_LIGHTNESS_DARK = 0.48f
|
||||||
|
|
||||||
|
/** Light-pole container: pale enough that dark ink clears 11:1 on every hue. */
|
||||||
|
const val LIGHT_CONTAINER_LIGHTNESS_LIGHT = 0.88f
|
||||||
|
|
||||||
|
/** Same in dark mode, held down a little so a pale block doesn't glare. */
|
||||||
|
const val LIGHT_CONTAINER_LIGHTNESS_DARK = 0.84f
|
||||||
|
|
||||||
|
/** Accent lightness: dark enough to read as a mark on a pale surface. */
|
||||||
|
const val ACCENT_LIGHTNESS_LIGHT = 0.55f
|
||||||
|
|
||||||
|
/** Same against a dark surface, where the mark has to be the light one. */
|
||||||
|
const val ACCENT_LIGHTNESS_DARK = 0.78f
|
||||||
|
|
||||||
|
/** Below this chroma a colour counts as grey and keeps no hue. */
|
||||||
|
const val GREY_CHROMA = 0.02f
|
||||||
|
|
||||||
|
/** Chroma band for harmonised colours: distinct, but never electric. */
|
||||||
|
const val MIN_CHROMA = 0.07f
|
||||||
|
const val MAX_CHROMA = 0.16f
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.color
|
||||||
|
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cbrt
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.hypot
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A colour in Oklch — Oklab's cylindrical form: perceptual [lightness], [chroma]
|
||||||
|
* (colourfulness) and [hue] in degrees.
|
||||||
|
*
|
||||||
|
* The point of using it over HSV is that its lightness axis matches what the eye
|
||||||
|
* calls brightness. HSV's "value" does not: at a single pinned value the hues
|
||||||
|
* spread across relative luminance 0.10 (indigo) to 0.45 (yellow), which is why
|
||||||
|
* pinning value produced fills that needed different ink per hue. Pin Oklch
|
||||||
|
* lightness instead and every hue lands at the same apparent brightness, so one
|
||||||
|
* ink serves all of them.
|
||||||
|
*
|
||||||
|
* [lightness] runs 0 (black) to 1 (white); [chroma] is 0 (grey) to about 0.32 at
|
||||||
|
* the sRGB limit.
|
||||||
|
*/
|
||||||
|
data class Oklch(val lightness: Float, val chroma: Float, val hue: Float) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perceptual distance to [other] — plain Euclidean in Oklab, which is what
|
||||||
|
* Oklab is built for (unlike CIE Lab, where CIE76 is known to misjudge
|
||||||
|
* saturated blues).
|
||||||
|
*/
|
||||||
|
fun distanceTo(other: Oklch): Float {
|
||||||
|
val (a1, b1) = chroma * cosDeg(hue) to chroma * sinDeg(hue)
|
||||||
|
val (a2, b2) = other.chroma * cosDeg(other.hue) to other.chroma * sinDeg(other.hue)
|
||||||
|
val dl = (lightness - other.lightness).toDouble()
|
||||||
|
return sqrt(dl * dl + (a1 - a2).pow(2) + (b1 - b2).pow(2)).toFloat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read [argb]'s opaque colour as Oklch. */
|
||||||
|
fun oklchOf(argb: Int): Oklch {
|
||||||
|
val r = toLinear(((argb shr 16) and 0xFF) / 255.0)
|
||||||
|
val g = toLinear(((argb shr 8) and 0xFF) / 255.0)
|
||||||
|
val b = toLinear((argb and 0xFF) / 255.0)
|
||||||
|
|
||||||
|
val l = cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
|
||||||
|
val m = cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
|
||||||
|
val s = cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
|
||||||
|
|
||||||
|
val lightness = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s
|
||||||
|
val aAxis = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s
|
||||||
|
val bAxis = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
|
||||||
|
|
||||||
|
val hue = (Math.toDegrees(atan2(bAxis, aAxis)) + 360.0) % 360.0
|
||||||
|
return Oklch(lightness.toFloat(), hypot(aAxis, bAxis).toFloat(), hue.toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The opaque sRGB colour for this Oklch, gamut-mapped: most of the Oklch cylinder
|
||||||
|
* falls outside sRGB, so a colour that does not fit keeps its lightness and hue
|
||||||
|
* and gives up chroma until it does. Holding lightness is what matters here —
|
||||||
|
* it's the axis the contrast guarantees rest on.
|
||||||
|
*/
|
||||||
|
fun Oklch.toArgb(): Int {
|
||||||
|
val fitted = if (inSrgb(lightness, chroma, hue)) {
|
||||||
|
chroma
|
||||||
|
} else {
|
||||||
|
var low = 0f
|
||||||
|
var high = chroma
|
||||||
|
repeat(GAMUT_STEPS) {
|
||||||
|
val mid = (low + high) / 2f
|
||||||
|
if (inSrgb(lightness, mid, hue)) low = mid else high = mid
|
||||||
|
}
|
||||||
|
low
|
||||||
|
}
|
||||||
|
val (r, g, b) = linearSrgbOf(lightness, fitted, hue)
|
||||||
|
return 0xFF shl 24 or
|
||||||
|
(channel(r) shl 16) or
|
||||||
|
(channel(g) shl 8) or
|
||||||
|
channel(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun linearSrgbOf(lightness: Float, chroma: Float, hue: Float): Triple<Double, Double, Double> {
|
||||||
|
val a = chroma * cosDeg(hue)
|
||||||
|
val b = chroma * sinDeg(hue)
|
||||||
|
val l = (lightness + 0.3963377774 * a + 0.2158037573 * b).pow(3)
|
||||||
|
val m = (lightness - 0.1055613458 * a - 0.0638541728 * b).pow(3)
|
||||||
|
val s = (lightness - 0.0894841775 * a - 1.2914855480 * b).pow(3)
|
||||||
|
return Triple(
|
||||||
|
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
||||||
|
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
||||||
|
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun inSrgb(lightness: Float, chroma: Float, hue: Float): Boolean {
|
||||||
|
val (r, g, b) = linearSrgbOf(lightness, chroma, hue)
|
||||||
|
return r in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON) &&
|
||||||
|
g in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON) &&
|
||||||
|
b in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun channel(linear: Double): Int =
|
||||||
|
(toSrgb(linear.coerceIn(0.0, 1.0)) * 255.0).toInt().coerceIn(0, 255)
|
||||||
|
|
||||||
|
private fun toLinear(c: Double): Double =
|
||||||
|
if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
|
||||||
|
|
||||||
|
private fun toSrgb(c: Double): Double =
|
||||||
|
if (c <= 0.0031308) 12.92 * c else 1.055 * c.pow(1.0 / 2.4) - 0.055
|
||||||
|
|
||||||
|
private fun cosDeg(deg: Float) = cos(Math.toRadians(deg.toDouble()))
|
||||||
|
private fun sinDeg(deg: Float) = sin(Math.toRadians(deg.toDouble()))
|
||||||
|
|
||||||
|
/** Bisection steps when pulling an out-of-gamut colour back into sRGB. */
|
||||||
|
private const val GAMUT_STEPS = 24
|
||||||
|
|
||||||
|
/** Slack for the gamut test, so rounding at the boundary doesn't reject a fit. */
|
||||||
|
private const val GAMUT_EPSILON = 1e-4
|
||||||
@@ -10,6 +10,7 @@ import androidx.compose.animation.slideOutHorizontally
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -27,6 +28,10 @@ import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
|
|||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.selectView
|
import de.jeanlucmakiola.calendula.ui.common.selectView
|
||||||
@@ -75,6 +80,7 @@ fun CalendarHost(
|
|||||||
requestedImportUri: android.net.Uri? = null,
|
requestedImportUri: android.net.Uri? = null,
|
||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
requestedInsertForm: EventForm? = null,
|
requestedInsertForm: EventForm? = null,
|
||||||
|
requestedInsertSource: ImportSource = ImportSource.Insert,
|
||||||
onInsertConsumed: () -> Unit = {},
|
onInsertConsumed: () -> Unit = {},
|
||||||
requestedEditKey: LongArray? = null,
|
requestedEditKey: LongArray? = null,
|
||||||
onEditKeyConsumed: () -> Unit = {},
|
onEditKeyConsumed: () -> Unit = {},
|
||||||
@@ -203,7 +209,7 @@ fun CalendarHost(
|
|||||||
// reveals on top of whatever was open without extra dismissal.
|
// reveals on top of whatever was open without extra dismissal.
|
||||||
LaunchedEffect(requestedInsertForm) {
|
LaunchedEffect(requestedInsertForm) {
|
||||||
if (requestedInsertForm != null) {
|
if (requestedInsertForm != null) {
|
||||||
importFormSource = ImportSource.Insert
|
importFormSource = requestedInsertSource
|
||||||
importForm = requestedInsertForm
|
importForm = requestedInsertForm
|
||||||
onInsertConsumed()
|
onInsertConsumed()
|
||||||
}
|
}
|
||||||
@@ -293,6 +299,33 @@ fun CalendarHost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drag to reschedule (#68). Hosted here so one instance serves every view and
|
||||||
|
// survives view switches; the calendar surfaces read it out of LocalEventMove.
|
||||||
|
val reschedule: RescheduleViewModel = hiltViewModel()
|
||||||
|
val movableCalendarIds by reschedule.movableCalendarIds.collectAsStateWithLifecycle()
|
||||||
|
val onEditEvent: (EventInstance) -> Unit = { event ->
|
||||||
|
val key = longArrayOf(
|
||||||
|
event.eventId,
|
||||||
|
event.start.toEpochMilliseconds(),
|
||||||
|
event.end.toEpochMilliseconds(),
|
||||||
|
)
|
||||||
|
heldEditKey = key
|
||||||
|
editKey = key
|
||||||
|
}
|
||||||
|
// Off by preference (#173) switches off the gesture, not the scope: blocks
|
||||||
|
// register no drag, while the edit form and its TalkBack action stay put.
|
||||||
|
val dragToReschedule = viewModel.dragToReschedule.collectAsStateWithLifecycle().value
|
||||||
|
val moveScope = remember(movableCalendarIds, reschedule, dragToReschedule) {
|
||||||
|
EventMoveScope(
|
||||||
|
movableCalendarIds = movableCalendarIds,
|
||||||
|
dragEnabled = dragToReschedule,
|
||||||
|
move = reschedule::move,
|
||||||
|
inFlight = reschedule.inFlight,
|
||||||
|
undoStarted = reschedule.undoStarted,
|
||||||
|
edit = onEditEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
|
||||||
// Base-level back: pop the view stack while no overlay covers it (each overlay
|
// Base-level back: pop the view stack while no overlay covers it (each overlay
|
||||||
@@ -310,6 +343,7 @@ fun CalendarHost(
|
|||||||
// navigation, so it fades through rather than sliding — paging *within* a
|
// navigation, so it fades through rather than sliding — paging *within* a
|
||||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
||||||
val viewSwitch = fadeThrough()
|
val viewSwitch = fadeThrough()
|
||||||
|
CompositionLocalProvider(LocalEventMove provides moveScope) {
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = view,
|
targetState = view,
|
||||||
transitionSpec = { viewSwitch },
|
transitionSpec = { viewSwitch },
|
||||||
@@ -366,6 +400,11 @@ fun CalendarHost(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scope prompt + confirmation/undo snackbar for a dropped event, declared
|
||||||
|
// right after the calendar views so later overlays cover it.
|
||||||
|
EventMoveHost(reschedule, modifier = Modifier.fillMaxSize())
|
||||||
|
|
||||||
// Search overlay — below detail/edit in the Box so a tapped result's
|
// Search overlay — below detail/edit in the Box so a tapped result's
|
||||||
// detail screen draws on top, and closing it returns to the results.
|
// detail screen draws on top, and closing it returns to the results.
|
||||||
|
|||||||
@@ -53,4 +53,18 @@ class CalendarHostViewModel @Inject constructor(
|
|||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = false,
|
initialValue = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether events may be dragged to another slot to reschedule them (#68, #173).
|
||||||
|
* Off until the stored value arrives, even though the preference itself defaults
|
||||||
|
* on: the wrong guess costs an enabled user a gesture for the few frames a cold
|
||||||
|
* start takes to read DataStore, but re-arms the one thing a disabled user
|
||||||
|
* switched off to be rid of.
|
||||||
|
*/
|
||||||
|
val dragToReschedule: StateFlow<Boolean> = prefs.dragToReschedule
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = false,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ package de.jeanlucmakiola.calendula.ui
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -22,13 +25,22 @@ import androidx.lifecycle.Lifecycle
|
|||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeDialog
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeViewModel
|
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingViewModel
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
import de.jeanlucmakiola.calendula.ui.onboarding.onboardingSlideTransition
|
||||||
|
import de.jeanlucmakiola.calendula.ui.onboarding.rememberOnboardingSlideSpec
|
||||||
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
|
||||||
|
/** What the root is showing: a wizard step, the app, or neither yet. */
|
||||||
|
private sealed interface RootTarget {
|
||||||
|
/** DataStore has not emitted yet — render nothing rather than the wrong screen. */
|
||||||
|
data object Loading : RootTarget
|
||||||
|
data object App : RootTarget
|
||||||
|
data class Step(val step: OnboardingStep) : RootTarget
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RootScreen(
|
fun RootScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -39,6 +51,8 @@ fun RootScreen(
|
|||||||
requestedImportUri: android.net.Uri? = null,
|
requestedImportUri: android.net.Uri? = null,
|
||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
||||||
|
requestedInsertSource: de.jeanlucmakiola.calendula.ui.edit.ImportSource =
|
||||||
|
de.jeanlucmakiola.calendula.ui.edit.ImportSource.Insert,
|
||||||
onInsertConsumed: () -> Unit = {},
|
onInsertConsumed: () -> Unit = {},
|
||||||
requestedEditKey: LongArray? = null,
|
requestedEditKey: LongArray? = null,
|
||||||
onEditKeyConsumed: () -> Unit = {},
|
onEditKeyConsumed: () -> Unit = {},
|
||||||
@@ -74,33 +88,63 @@ fun RootScreen(
|
|||||||
onDispose { lifecycle.removeObserver(obs) }
|
onDispose { lifecycle.removeObserver(obs) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cross-fade the one-time onboarding gates so granting permission / finishing
|
val onboarding: OnboardingViewModel = hiltViewModel()
|
||||||
// onboarding eases into the next screen instead of snapping. A fade carries no
|
val plan by onboarding.plan.collectAsStateWithLifecycle()
|
||||||
// spatial motion, so it stays appropriate under "remove animations" too.
|
// The grant is reported however it was taken, including via Android's
|
||||||
val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
// app-settings screen (caught by the ON_RESUME above); arming the wizard
|
||||||
Crossfade(targetState = hasPermission, animationSpec = gateSpec, label = "permissionGate") { granted ->
|
// hangs off that, not off the in-app callback.
|
||||||
if (granted) {
|
LaunchedEffect(hasPermission) {
|
||||||
// Second onboarding gate (v1.4, one-time): reminder notifications.
|
onboarding.setHasPermission(hasPermission)
|
||||||
// Null until DataStore's first emission — render nothing for that
|
if (hasPermission && !grantedAtLaunch) onboarding.onPermissionGranted()
|
||||||
// frame instead of flashing the wrong screen.
|
|
||||||
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
|
||||||
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
|
||||||
// One-time explainer for the switch to the device's own calendar
|
|
||||||
// visibility (#75), armed by the reconciler.
|
|
||||||
val visibilityNotice: CalendarVisibilityNoticeViewModel = hiltViewModel()
|
|
||||||
val noticePending by visibilityNotice.pending.collectAsStateWithLifecycle()
|
|
||||||
// Runs on entry however the permission was granted, including via
|
|
||||||
// Android's app-settings screen (caught by the ON_RESUME above).
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
visibilityNotice.reconcile()
|
|
||||||
if (!grantedAtLaunch) reminderOnboarding.rearmAfterGrant()
|
|
||||||
}
|
}
|
||||||
if (onboardingDone == true && noticePending) {
|
|
||||||
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
|
// Arms the one-time explainer for the switch to the device's own calendar
|
||||||
|
// visibility (#75), which the wizard then carries as a step of its own.
|
||||||
|
LaunchedEffect(hasPermission) {
|
||||||
|
if (hasPermission) onboarding.reconcileCalendarVisibility()
|
||||||
}
|
}
|
||||||
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
|
||||||
when (done) {
|
val current = plan
|
||||||
true -> CalendarHost(
|
val target: RootTarget = when {
|
||||||
|
current == null -> RootTarget.Loading
|
||||||
|
current.current == null -> RootTarget.App
|
||||||
|
else -> RootTarget.Step(current.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which way the flow is moving, so a step-back slides back. The app sorts
|
||||||
|
// after every step, so finishing the wizard reads as one more move forward.
|
||||||
|
val ordinal = when (target) {
|
||||||
|
RootTarget.Loading -> Int.MIN_VALUE
|
||||||
|
RootTarget.App -> Int.MAX_VALUE
|
||||||
|
is RootTarget.Step -> current?.steps?.indexOf(target.step) ?: 0
|
||||||
|
}
|
||||||
|
var lastOrdinal by remember { mutableIntStateOf(ordinal) }
|
||||||
|
val slideDir = if (ordinal < lastOrdinal) -1 else 1
|
||||||
|
SideEffect { lastOrdinal = ordinal }
|
||||||
|
|
||||||
|
val slideSpec = rememberOnboardingSlideSpec()
|
||||||
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = target,
|
||||||
|
transitionSpec = {
|
||||||
|
// Coming off the blank first frame is not a step change.
|
||||||
|
if (initialState == RootTarget.Loading) {
|
||||||
|
fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
||||||
|
} else {
|
||||||
|
onboardingSlideTransition(
|
||||||
|
slideDir = slideDir,
|
||||||
|
spec = slideSpec,
|
||||||
|
fadeSpec = fadeSpec,
|
||||||
|
reduceMotion = reduceMotion,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = "onboardingGate",
|
||||||
|
) { shown ->
|
||||||
|
when (shown) {
|
||||||
|
RootTarget.Loading -> Unit
|
||||||
|
RootTarget.App -> CalendarHost(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
requestedDetailKey = requestedDetailKey,
|
requestedDetailKey = requestedDetailKey,
|
||||||
onDetailKeyConsumed = onDetailKeyConsumed,
|
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||||
@@ -109,22 +153,22 @@ fun RootScreen(
|
|||||||
requestedImportUri = requestedImportUri,
|
requestedImportUri = requestedImportUri,
|
||||||
onImportConsumed = onImportConsumed,
|
onImportConsumed = onImportConsumed,
|
||||||
requestedInsertForm = requestedInsertForm,
|
requestedInsertForm = requestedInsertForm,
|
||||||
|
requestedInsertSource = requestedInsertSource,
|
||||||
onInsertConsumed = onInsertConsumed,
|
onInsertConsumed = onInsertConsumed,
|
||||||
requestedEditKey = requestedEditKey,
|
requestedEditKey = requestedEditKey,
|
||||||
onEditKeyConsumed = onEditKeyConsumed,
|
onEditKeyConsumed = onEditKeyConsumed,
|
||||||
)
|
)
|
||||||
false -> ReminderOnboardingScreen(
|
// Both halves of a transition read their position out of the live
|
||||||
onFinished = reminderOnboarding::finish,
|
// plan; the one the outgoing step was drawn with is already gone.
|
||||||
|
is RootTarget.Step -> current?.let { plan ->
|
||||||
|
OnboardingSteps(
|
||||||
|
step = shown.step,
|
||||||
|
plan = plan,
|
||||||
|
viewModel = onboarding,
|
||||||
|
onPermissionGranted = { hasPermission = true },
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
null -> {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
PermissionScreen(
|
|
||||||
onGranted = { hasPermission = true },
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
@@ -137,7 +138,7 @@ internal fun AgendaEventRow(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(width = 6.dp, height = 36.dp)
|
.size(width = 6.dp, height = 36.dp)
|
||||||
.clip(RoundedCornerShape(3.dp))
|
.clip(RoundedCornerShape(3.dp))
|
||||||
.background(eventFill(event.color, dark, soften)),
|
.background(eventAccent(event.color, dark, soften)),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ fun AgendaScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = anchor,
|
currentDate = anchor,
|
||||||
|
drawerState = drawerState,
|
||||||
viewOrder = drawerViewOrder,
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
@@ -275,7 +276,7 @@ private fun AgendaRangeBanner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaContent(
|
internal fun AgendaContent(
|
||||||
state: AgendaUiState,
|
state: AgendaUiState,
|
||||||
pastDisplay: PastEventDisplay,
|
pastDisplay: PastEventDisplay,
|
||||||
showToday: Boolean,
|
showToday: Boolean,
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.sampleAgendaEvents
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/** How far ahead the agenda preview's stand-in window runs. */
|
||||||
|
private const val PREVIEW_WINDOW_DAYS = 14
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A live, scaled-down Agenda view for the onboarding view chooser. Today and
|
||||||
|
* the day headers are real; only the events are stand-ins.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AgendaViewPreview(
|
||||||
|
height: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
|
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||||
|
val state = remember(today, zone) { sampleAgendaState(today, zone) }
|
||||||
|
|
||||||
|
ScaledViewPreview(height = height, modifier = modifier) {
|
||||||
|
AgendaContent(
|
||||||
|
state = state,
|
||||||
|
// Stand-in events are all upcoming, so the past-event preference
|
||||||
|
// cannot change what the preview shows — keep it at the default.
|
||||||
|
pastDisplay = PastEventDisplay.SHOW,
|
||||||
|
showToday = true,
|
||||||
|
onRetry = {},
|
||||||
|
onEventClick = {},
|
||||||
|
onOpenDay = {},
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sample agenda state, grouped through the same helper the live view uses. */
|
||||||
|
private fun sampleAgendaState(today: LocalDate, zone: TimeZone): AgendaUiState.Success {
|
||||||
|
val windowEnd = today.plus(PREVIEW_WINDOW_DAYS, DateTimeUnit.DAY)
|
||||||
|
return AgendaUiState.Success(
|
||||||
|
anchor = today,
|
||||||
|
today = today,
|
||||||
|
days = groupAgendaDays(today, windowEnd, sampleAgendaEvents(today, zone), zone),
|
||||||
|
range = AgendaRange.Custom(PREVIEW_WINDOW_DAYS),
|
||||||
|
rangeIsOverride = false,
|
||||||
|
rangeEnd = windowEnd,
|
||||||
|
// The range bar belongs to the screen, not the list — the preview shows
|
||||||
|
// the list only.
|
||||||
|
showRangeBar = false,
|
||||||
|
zone = zone,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.calendars
|
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The one-time notice that Calendula now follows the device's per-calendar
|
|
||||||
* visibility (#75), armed by `CalendarVisibilityReconciler`. The app does not
|
|
||||||
* switch those calendars back on — that would un-hide them everywhere else too.
|
|
||||||
*/
|
|
||||||
@HiltViewModel
|
|
||||||
class CalendarVisibilityNoticeViewModel @Inject constructor(
|
|
||||||
private val prefs: CalendarPrefs,
|
|
||||||
private val reconciler: CalendarVisibilityReconciler,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reconcile whenever the app comes up with the calendar permission held,
|
|
||||||
* rather than off one grant route: a permission granted on Android's
|
|
||||||
* app-settings screen never reaches the permission screen's callback.
|
|
||||||
*/
|
|
||||||
fun reconcile() {
|
|
||||||
viewModelScope.launch { reconciler.run() }
|
|
||||||
}
|
|
||||||
|
|
||||||
val pending: StateFlow<Boolean> = prefs.visibilityNoticePending
|
|
||||||
.map { it == true }
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun dismiss() {
|
|
||||||
viewModelScope.launch { prefs.setVisibilityNoticePending(false) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Plain informational dialog — one acknowledgement, nothing to decide. */
|
|
||||||
@Composable
|
|
||||||
fun CalendarVisibilityNoticeDialog(onDismiss: () -> Unit) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
icon = { Icon(Icons.Default.VisibilityOff, contentDescription = null) },
|
|
||||||
title = { Text(stringResource(R.string.calendars_visibility_notice_title)) },
|
|
||||||
text = { Text(stringResource(R.string.calendars_visibility_notice_message)) },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text(stringResource(R.string.dialog_ok))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -88,6 +88,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||||
@@ -442,7 +443,7 @@ private fun CalendarEditor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) {
|
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) {
|
||||||
InlineTextField(
|
InlineTextField(
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.snap
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A timed block's own time label, crossfaded rather than replaced — the block
|
||||||
|
* slides to its new slot, so the label shouldn't change in a single frame.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
|
||||||
|
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
|
||||||
|
snap()
|
||||||
|
} else {
|
||||||
|
MaterialTheme.motionScheme.fastEffectsSpec()
|
||||||
|
}
|
||||||
|
Crossfade(
|
||||||
|
targetState = label,
|
||||||
|
animationSpec = spec,
|
||||||
|
label = "block-time",
|
||||||
|
modifier = modifier,
|
||||||
|
) { text ->
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = color,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a timed block sits in its column, after tweening. */
|
||||||
|
@Immutable
|
||||||
|
data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A timed block's placement, tweened rather than jumped. Continuity comes from
|
||||||
|
* the caller keying each block by identity; a block composed for the first time
|
||||||
|
* starts at its target, so nothing flies in on the first frame.
|
||||||
|
*
|
||||||
|
* A pinch-zoom rewrites the hour height every pointer frame, and the gutter and
|
||||||
|
* grid lines follow it instantly — so the tween stands down for the gesture
|
||||||
|
* rather than leaving the blocks trailing the ruler they are measured against.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement {
|
||||||
|
val spec: FiniteAnimationSpec<Dp> = if (rememberReduceMotion() ||
|
||||||
|
LocalTimelineZoom.current.isPinching
|
||||||
|
) {
|
||||||
|
snap()
|
||||||
|
} else {
|
||||||
|
MaterialTheme.motionScheme.fastSpatialSpec()
|
||||||
|
}
|
||||||
|
val animatedX by animateDpAsState(x, spec, label = "block-x")
|
||||||
|
val animatedY by animateDpAsState(y, spec, label = "block-y")
|
||||||
|
val animatedWidth by animateDpAsState(width, spec, label = "block-width")
|
||||||
|
val animatedHeight by animateDpAsState(height, spec, label = "block-height")
|
||||||
|
return BlockPlacement(animatedX, animatedY, animatedWidth, animatedHeight)
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.CalendarMonth,
|
Icons.Filled.CalendarMonth,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = eventFill(color, dark, soften),
|
tint = eventAccent(color, dark, soften),
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -19,6 +20,7 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.DateRange
|
import androidx.compose.material.icons.filled.DateRange
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.DrawerState
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalDrawerSheet
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
@@ -27,19 +29,23 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
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.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
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.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,7 +56,8 @@ import kotlinx.datetime.LocalDate
|
|||||||
* a jump-to-date action, the per-calendar visibility filter (M3) inline, and a
|
* a jump-to-date action, the per-calendar visibility filter (M3) inline, and a
|
||||||
* pinned Settings row. The "View" section mirrors the top-bar switcher pill —
|
* pinned Settings row. The "View" section mirrors the top-bar switcher pill —
|
||||||
* tapping a view here selects it (and closes the drawer) rather than cycling.
|
* tapping a view here selects it (and closes the drawer) rather than cycling.
|
||||||
* The host screen owns the drawer state.
|
* The host screen owns the drawer state; the sheet reads it only to dismiss
|
||||||
|
* itself on back.
|
||||||
*
|
*
|
||||||
* [currentDate] seeds the jump-to-date picker (the visible day/week-start/month
|
* [currentDate] seeds the jump-to-date picker (the visible day/week-start/month
|
||||||
* anchor); [onJumpToDate] navigates the active view to the chosen day.
|
* anchor); [onJumpToDate] navigates the active view to the chosen day.
|
||||||
@@ -59,12 +66,17 @@ import kotlinx.datetime.LocalDate
|
|||||||
fun CalendarDrawer(
|
fun CalendarDrawer(
|
||||||
currentView: CalendarView,
|
currentView: CalendarView,
|
||||||
currentDate: LocalDate,
|
currentDate: LocalDate,
|
||||||
|
drawerState: DrawerState,
|
||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
) {
|
) {
|
||||||
var showDatePicker by remember { mutableStateOf(false) }
|
var showDatePicker by remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Registered in the sheet so it takes precedence over the host's back handler.
|
||||||
|
BackHandler(enabled = drawerState.isOpen) { scope.launch { drawerState.close() } }
|
||||||
|
|
||||||
ModalDrawerSheet {
|
ModalDrawerSheet {
|
||||||
// The whole sidebar scrolls as one — header, views, the calendar filter
|
// The whole sidebar scrolls as one — header, views, the calendar filter
|
||||||
@@ -83,7 +95,7 @@ fun CalendarDrawer(
|
|||||||
position = positionOf(index, viewOrder.size),
|
position = positionOf(index, viewOrder.size),
|
||||||
selected = view == currentView,
|
selected = view == currentView,
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
leading = { Icon(view.icon, contentDescription = null) },
|
leading = { DrawerLeadingIcon(view.icon) },
|
||||||
onClick = { onSelectView(view) },
|
onClick = { onSelectView(view) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -93,7 +105,7 @@ fun CalendarDrawer(
|
|||||||
title = stringResource(R.string.drawer_jump_to_date),
|
title = stringResource(R.string.drawer_jump_to_date),
|
||||||
position = Position.Alone,
|
position = Position.Alone,
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
leading = { Icon(Icons.Filled.DateRange, contentDescription = null) },
|
leading = { DrawerLeadingIcon(Icons.Filled.DateRange) },
|
||||||
onClick = { showDatePicker = true },
|
onClick = { showDatePicker = true },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -107,7 +119,7 @@ fun CalendarDrawer(
|
|||||||
title = stringResource(R.string.month_action_settings),
|
title = stringResource(R.string.month_action_settings),
|
||||||
position = Position.Alone,
|
position = Position.Alone,
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
leading = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
leading = { DrawerLeadingIcon(Icons.Filled.Settings) },
|
||||||
onClick = onSettings,
|
onClick = onSettings,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
@@ -126,13 +138,27 @@ fun CalendarDrawer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Leading slot for the drawer's plain icons: the same 40.dp footprint
|
||||||
|
* [CalendarColorChip] takes, so every leading glyph shares one vertical axis. */
|
||||||
|
@Composable
|
||||||
|
private fun DrawerLeadingIcon(icon: ImageVector) {
|
||||||
|
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Icon(icon, contentDescription = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Branded header: the app-icon chip beside the app name. */
|
/** Branded header: the app-icon chip beside the app name. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun DrawerHeader() {
|
private fun DrawerHeader() {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 28.dp, end = 28.dp, top = 24.dp, bottom = 16.dp),
|
.padding(
|
||||||
|
start = GroupedListInset,
|
||||||
|
end = GroupedListInset,
|
||||||
|
top = 24.dp,
|
||||||
|
bottom = 16.dp,
|
||||||
|
),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
@@ -164,6 +190,11 @@ private fun DrawerSectionHeader(text: String) {
|
|||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
modifier = Modifier.padding(
|
||||||
|
start = GroupedListInset,
|
||||||
|
end = GroupedListInset,
|
||||||
|
top = 16.dp,
|
||||||
|
bottom = 8.dp,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
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.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-bar title that doubles as the jump-to-date entry point: the formatted
|
||||||
|
* [title] with a drop-down caret, opening the same [CalendarDatePickerDialog] the
|
||||||
|
* drawer uses and reporting the chosen day through [onJumpToDate].
|
||||||
|
*
|
||||||
|
* [currentDate] seeds the picker with whatever the bar is currently naming (the
|
||||||
|
* visible day, week start or month anchor).
|
||||||
|
*
|
||||||
|
* The row keeps its own 8.dp inset rather than shifting back onto the app bar's
|
||||||
|
* start alignment: M3 places the title flush against the navigation icon's
|
||||||
|
* trailing edge, and the title is hit-tested on top of it, so a negative offset
|
||||||
|
* would swallow taps meant for the menu button.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CalendarTitleButton(
|
||||||
|
title: String,
|
||||||
|
currentDate: LocalDate,
|
||||||
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var showDatePicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
|
.clip(MaterialTheme.shapes.large)
|
||||||
|
.clickable(
|
||||||
|
onClickLabel = stringResource(R.string.drawer_jump_to_date),
|
||||||
|
role = Role.Button,
|
||||||
|
) { showDatePicker = true }
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowDropDown,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDatePicker) {
|
||||||
|
CalendarDatePickerDialog(
|
||||||
|
initial = currentDate,
|
||||||
|
onConfirm = {
|
||||||
|
showDatePicker = false
|
||||||
|
onJumpToDate(it)
|
||||||
|
},
|
||||||
|
onDismiss = { showDatePicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,45 +2,53 @@ package de.jeanlucmakiola.calendula.ui.common
|
|||||||
|
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.luminance
|
import de.jeanlucmakiola.calendula.domain.color.eventTone
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.calendula.domain.color.inkFor
|
||||||
|
import de.jeanlucmakiola.calendula.domain.color.oklchOf
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether calendar/event colours are softened toward theme-fitting pastels
|
* Whether calendar/event colours are harmonised — hue kept, lightness re-pinned
|
||||||
* before display (issue #36). Provided app-wide from the "soften colours"
|
* per role — before display (issue #36). Provided app-wide from the setting; the
|
||||||
* setting; the default `true` keeps the historical look. When off, the raw
|
* default `true` is the app's own look. When off, the raw provider colour is
|
||||||
* provider colour is painted verbatim — matching the sync source (DAVx5/CalDAV)
|
* painted verbatim, matching the sync source (DAVx5/CalDAV) and other calendar
|
||||||
* and other calendar apps. Widgets live outside this composition and read the
|
* apps. Widgets live outside this composition and read the preference directly,
|
||||||
* preference directly, then pass the flag to [eventFill] / [eventInk].
|
* then pass the flag through.
|
||||||
*/
|
*/
|
||||||
val LocalSoftenColors = staticCompositionLocalOf { true }
|
val LocalSoftenColors = staticCompositionLocalOf { true }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display fill for an event chip/bar or a calendar tint: the [pastelize]d colour
|
* Fill for a surface that carries text on it — a week/day block, a month bar, a
|
||||||
* when [soften] is on, else the raw provider ARGB verbatim (forced opaque, since
|
* widget row, a picker swatch. Pair it with [eventInk].
|
||||||
* pastelize also returns an opaque colour).
|
|
||||||
*/
|
*/
|
||||||
fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
||||||
if (soften) pastelize(rawArgb, dark) else Color(rawArgb or 0xFF000000.toInt())
|
Color(eventTone(rawArgb, dark, soften).container)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contrast ink (title text / glyph) for a filled chip painted with [eventFill]:
|
* Colour for a mark on an ordinary app surface — a day dot, an agenda or search
|
||||||
* white on a dark fill, near-black on a light one (issue #21). Applies to both
|
* stripe, a calendar icon tint, the detail header. Same identity as [eventFill],
|
||||||
* softened and raw fills — a saturated hue (deep blue, purple, red) is
|
* pinned to contrast with the surface instead of with ink.
|
||||||
* perceptually dark even after softening pins its HSV value, so black text on it
|
|
||||||
* reads poorly. The choice is objective, not a tuned threshold: white wins only
|
|
||||||
* when it out-contrasts black against the fill, which (by the WCAG contrast
|
|
||||||
* ratio) is at relative luminance ≈ 0.18 — so mid and light colours keep
|
|
||||||
* near-black text. [alpha] carries the caller's soft emphasis.
|
|
||||||
*/
|
*/
|
||||||
fun eventInk(fill: Color, alpha: Float = 0.8f): Color {
|
fun eventAccent(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
||||||
val useWhite = fill.luminance() < INK_LUMINANCE_CROSSOVER
|
Color(eventTone(rawArgb, dark, soften).accent)
|
||||||
return (if (useWhite) Color.White else Color.Black).copy(alpha = alpha)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Relative-luminance crossover where white text starts to out-contrast black.
|
* Ink for text drawn on an [eventFill]. [alpha] carries the caller's emphasis.
|
||||||
* Solving `contrast(white, L) = contrast(black, L)` on the WCAG ratio gives
|
*
|
||||||
* `L = sqrt(1.05 * 0.05) - 0.05 ≈ 0.179`.
|
* Takes the fill rather than the raw colour so it stays correct for the raw
|
||||||
|
* (un-harmonised) path too, where the provider decides the lightness.
|
||||||
*/
|
*/
|
||||||
private const val INK_LUMINANCE_CROSSOVER = 0.179f
|
fun eventInk(fill: Color, alpha: Float = 0.8f): Color =
|
||||||
|
Color(inkFor(oklchOf(fill.toArgbInt()).lightness)).copy(alpha = alpha)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ink alpha for a block's secondary line (the time under the title). Held above
|
||||||
|
* a fainter value because harmonised containers are deep: the small time text
|
||||||
|
* needs to stay clear of the 4.5:1 WCAG asks of body text.
|
||||||
|
*/
|
||||||
|
const val SECONDARY_INK_ALPHA = 0.8f
|
||||||
|
|
||||||
|
private fun Color.toArgbInt(): Int =
|
||||||
|
(0xFF shl 24) or
|
||||||
|
((red * 255f).toInt().coerceIn(0, 255) shl 16) or
|
||||||
|
((green * 255f).toInt().coerceIn(0, 255) shl 8) or
|
||||||
|
(blue * 255f).toInt().coerceIn(0, 255)
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/** How far the finger may wander during the hold — well under touch slop. */
|
||||||
|
private val PICKUP_TOLERANCE = 6.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick an event block up with a long press and drag it, without
|
||||||
|
* `detectDragGesturesAfterLongPress`: the stock detector cancels as soon as an
|
||||||
|
* ancestor consumes or the finger leaves the block, which a
|
||||||
|
* `MIN_EVENT_FRACTION`-tall block loses immediately. Movement before the timeout
|
||||||
|
* is deliberately not consumed, so a scroll starting on top of a block survives.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberEventDragSource(
|
||||||
|
enabled: Boolean,
|
||||||
|
key: Any?,
|
||||||
|
onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> Unit,
|
||||||
|
onMove: (pointerInRoot: Offset) -> Unit,
|
||||||
|
onDrop: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
): Modifier = rememberDragSurface(
|
||||||
|
enabled = enabled,
|
||||||
|
key = key,
|
||||||
|
onPickUp = { _, pointerInRoot, nodeInRoot, size ->
|
||||||
|
onPickUp(pointerInRoot, nodeInRoot, size)
|
||||||
|
true
|
||||||
|
},
|
||||||
|
onMove = onMove,
|
||||||
|
onDrop = onDrop,
|
||||||
|
onCancel = onCancel,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same pickup, for a surface that carries many draggable pieces rather than
|
||||||
|
* being one itself — the month grid, whose chips are covered by a full-bleed tap
|
||||||
|
* layer. [onPickUp] receives the press position local to this node and answers
|
||||||
|
* whether anything is there; false abandons the gesture.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberDragSurface(
|
||||||
|
enabled: Boolean,
|
||||||
|
key: Any?,
|
||||||
|
onPickUp: (local: Offset, pointerInRoot: Offset, nodeInRoot: Offset, size: IntSize) -> Boolean,
|
||||||
|
onMove: (pointerInRoot: Offset) -> Unit,
|
||||||
|
onDrop: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
): Modifier {
|
||||||
|
if (!enabled) return Modifier
|
||||||
|
val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis
|
||||||
|
val tolerance = with(LocalDensity.current) { PICKUP_TOLERANCE.toPx() }
|
||||||
|
val coordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
|
||||||
|
val currentPickUp by rememberUpdatedState(onPickUp)
|
||||||
|
val currentMove by rememberUpdatedState(onMove)
|
||||||
|
val currentDrop by rememberUpdatedState(onDrop)
|
||||||
|
val currentCancel by rememberUpdatedState(onCancel)
|
||||||
|
|
||||||
|
return Modifier
|
||||||
|
.onGloballyPositioned { coordinates[0] = it }
|
||||||
|
.pointerInput(key) {
|
||||||
|
awaitEachGesture {
|
||||||
|
val down = awaitFirstDown(requireUnconsumed = false)
|
||||||
|
val origin = down.position
|
||||||
|
val heldStill = withTimeoutOrNull(holdMillis) {
|
||||||
|
while (true) {
|
||||||
|
val main = awaitPointerEvent()
|
||||||
|
val change = main.changes.firstOrNull { it.id == down.id } ?: break
|
||||||
|
if (!change.pressed) break
|
||||||
|
if (main.changes.count { it.pressed } > 1) break
|
||||||
|
if ((change.position - origin).getDistance() > tolerance) break
|
||||||
|
// An ancestor's claim only becomes visible once the whole
|
||||||
|
// main pass has run, so look again on the final pass.
|
||||||
|
val final = awaitPointerEvent(PointerEventPass.Final)
|
||||||
|
if (final.changes.any { it.isConsumed }) break
|
||||||
|
}
|
||||||
|
} == null
|
||||||
|
if (!heldStill) return@awaitEachGesture
|
||||||
|
|
||||||
|
val layout = coordinates[0]?.takeIf { it.isAttached } ?: return@awaitEachGesture
|
||||||
|
val took = currentPickUp(
|
||||||
|
down.position,
|
||||||
|
layout.localToRoot(down.position),
|
||||||
|
layout.positionInRoot(),
|
||||||
|
layout.size,
|
||||||
|
)
|
||||||
|
if (!took) return@awaitEachGesture
|
||||||
|
var dropped = false
|
||||||
|
try {
|
||||||
|
// Driven on the initial pass, which runs parent → child: an
|
||||||
|
// ancestor that outranks us (the pinch) has already consumed
|
||||||
|
// by the time we look, and descendants — the month grid's
|
||||||
|
// full-bleed tap layer — see our consumption.
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||||
|
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||||
|
if (change.isConsumed) break
|
||||||
|
if (!change.pressed) {
|
||||||
|
change.consume()
|
||||||
|
dropped = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (event.changes.count { it.pressed } > 1) break
|
||||||
|
change.consume()
|
||||||
|
coordinates[0]?.takeIf { it.isAttached }
|
||||||
|
?.let { currentMove(it.localToRoot(change.position)) }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Also reached when the pointer node is disposed mid-drag —
|
||||||
|
// a cancel, not a drop, and must never write.
|
||||||
|
if (dropped) currentDrop() else currentCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A lift on pickup, then a tick every time the drop target snaps to a new slot. */
|
||||||
|
@Composable
|
||||||
|
fun DragSnapHaptics(slot: Any?) {
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
val previous = remember { arrayOfNulls<Any>(1) }
|
||||||
|
LaunchedEffect(slot) {
|
||||||
|
val had = previous[0]
|
||||||
|
previous[0] = slot
|
||||||
|
when {
|
||||||
|
slot == null -> Unit
|
||||||
|
had == null -> haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
had != slot -> haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChip
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/** How long the confirmation chip stays up, matching a short snackbar. */
|
||||||
|
private const val CHIP_MILLIS = 4_000L
|
||||||
|
|
||||||
|
/** How long an *undone* move stays up — shorter, since it offers nothing to act on. */
|
||||||
|
private const val UNDONE_CHIP_MILLIS = 1_600L
|
||||||
|
|
||||||
|
/** What the chip currently reads, kept past the outcome it was built from. */
|
||||||
|
private data class ChipContent(val message: String, val undo: MoveUndo?)
|
||||||
|
|
||||||
|
/** The FAB's own band at the bottom end, which the chip must not run into. */
|
||||||
|
private val FAB_BAND = 88.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two surfaces a drag-and-drop reschedule needs on top of the calendar: the
|
||||||
|
* recurring-scope prompt, and the confirmation chip carrying Undo. None of the
|
||||||
|
* calendar screens sets a `snackbarHost`, so this hosts its own confirmation as
|
||||||
|
* a pill on the FAB's band, leaving the calendar visible behind it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) {
|
||||||
|
val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
|
||||||
|
val outcome by viewModel.outcome.collectAsStateWithLifecycle()
|
||||||
|
val undoTick by viewModel.undoStarted.collectAsStateWithLifecycle()
|
||||||
|
val writeInFlight by viewModel.inFlight.collectAsStateWithLifecycle()
|
||||||
|
val locale = currentLocale()
|
||||||
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
|
|
||||||
|
prompt?.let { pending ->
|
||||||
|
RecurringScopeDialog(
|
||||||
|
title = stringResource(R.string.event_move_recurring_title),
|
||||||
|
onSelect = viewModel::moveWithScope,
|
||||||
|
onDismiss = viewModel::cancelScope,
|
||||||
|
allowSeries = !pending.occurrenceOnly,
|
||||||
|
reason = stringResource(R.string.event_move_occurrence_only)
|
||||||
|
.takeIf { pending.occurrenceOnly },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val moved = outcome as? MoveOutcome.Moved
|
||||||
|
val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) }
|
||||||
|
val message = when (outcome) {
|
||||||
|
null -> null
|
||||||
|
is MoveOutcome.Moved -> stringResource(R.string.event_move_done, movedLabel.orEmpty())
|
||||||
|
MoveOutcome.Undone -> stringResource(R.string.event_move_undone)
|
||||||
|
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
|
||||||
|
MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
|
||||||
|
MoveOutcome.BlockedSeriesEnd -> stringResource(R.string.event_move_blocked_series_end)
|
||||||
|
MoveOutcome.Failed -> stringResource(R.string.event_move_failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Held past the outcome being consumed so the chip has something to draw
|
||||||
|
// while it springs back out. Updated in composition rather than from an
|
||||||
|
// effect, which would land a frame late and open the chip on stale text.
|
||||||
|
val shown = remember { mutableStateOf(ChipContent("", null)) }
|
||||||
|
if (message != null && (shown.value.message != message || shown.value.undo != moved?.undo)) {
|
||||||
|
shown.value = ChipContent(message, moved?.undo)
|
||||||
|
}
|
||||||
|
val content = shown.value
|
||||||
|
// Restarted by the undo tick and held while a write is in flight: an undo
|
||||||
|
// tapped in the last moments of the window leaves the outcome at Moved on
|
||||||
|
// purpose, and this timer would otherwise fire mid-undo and close the chip
|
||||||
|
// just before the same chip has to say "undone".
|
||||||
|
LaunchedEffect(outcome, undoTick, writeInFlight) {
|
||||||
|
if (outcome == null || writeInFlight) return@LaunchedEffect
|
||||||
|
delay(if (outcome == MoveOutcome.Undone) UNDONE_CHIP_MILLIS else CHIP_MILLIS)
|
||||||
|
viewModel.consumeOutcome()
|
||||||
|
}
|
||||||
|
|
||||||
|
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||||
|
val chipMaxWidth = maxWidth - FAB_BAND
|
||||||
|
// A FAB-height band at the bottom start with the FAB's own margin, so
|
||||||
|
// the chip lines up beside the bottom-end FAB.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(start = SnackChipMargin, bottom = SnackChipMargin)
|
||||||
|
.height(SnackChipHeight),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
// The action goes away while a write runs: undo() refuses a second
|
||||||
|
// write anyway, so offering it would be a button that does nothing.
|
||||||
|
val undo = content.undo?.takeIf { !writeInFlight }
|
||||||
|
SnackChip(
|
||||||
|
visible = outcome != null,
|
||||||
|
message = content.message,
|
||||||
|
maxWidth = chipMaxWidth,
|
||||||
|
actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null },
|
||||||
|
onAction = undo?.let { { viewModel.undo(it) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Fri, 7 Aug, 09:00" — the day, plus the time for a timed event. All-day events
|
||||||
|
* are read back on the UTC calendar day they are anchored to (#65, #82).
|
||||||
|
*/
|
||||||
|
private fun formatMovedTo(
|
||||||
|
startMillis: Long,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
use24Hour: Boolean,
|
||||||
|
locale: Locale,
|
||||||
|
): String {
|
||||||
|
val zone: ZoneId = if (isAllDay) ZoneOffset.UTC else ZoneId.systemDefault()
|
||||||
|
val skeleton = when {
|
||||||
|
isAllDay -> "EEEdMMM"
|
||||||
|
use24Hour -> "EEEdMMMHm"
|
||||||
|
else -> "EEEdMMMhm"
|
||||||
|
}
|
||||||
|
return localizedDateFormatter(locale, skeleton)
|
||||||
|
.format(Instant.ofEpochMilli(startMillis).atZone(zone))
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by
|
||||||
|
* whichever event block is being composed. Null means moving is off entirely and
|
||||||
|
* blocks register no drag gesture at all.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
class EventMoveScope(
|
||||||
|
/**
|
||||||
|
* Calendars whose events may be moved. Nothing below the UI enforces this —
|
||||||
|
* the repository writes whatever it is handed — so this gate is load-bearing.
|
||||||
|
*/
|
||||||
|
val movableCalendarIds: Set<Long>,
|
||||||
|
/**
|
||||||
|
* Whether the *gesture* is on (#173). Off gates the drag alone: [edit] and the
|
||||||
|
* TalkBack action below stay, since invoking a menu item can't cause the
|
||||||
|
* accidental drags the setting exists to stop.
|
||||||
|
*/
|
||||||
|
val dragEnabled: Boolean,
|
||||||
|
/** False when the drop was refused outright, so nothing will be written. */
|
||||||
|
val move: (MoveRequest) -> Boolean,
|
||||||
|
/**
|
||||||
|
* True while a dropped event is being written, including the time its scope
|
||||||
|
* dialog is up. A flow rather than a value so this scope stays the same
|
||||||
|
* object across a move — every visible block reads it as a composition local.
|
||||||
|
*/
|
||||||
|
val inFlight: StateFlow<Boolean>,
|
||||||
|
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
|
||||||
|
val undoStarted: StateFlow<Int>,
|
||||||
|
/** Open an event in the edit form — the pointer-free route to the same change. */
|
||||||
|
val edit: (EventInstance) -> Unit,
|
||||||
|
) {
|
||||||
|
fun allows(event: EventInstance): Boolean = event.calendarId in movableCalendarIds
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalEventMove = compositionLocalOf<EventMoveScope?> { null }
|
||||||
|
|
||||||
|
private val NEVER_IN_FLIGHT = MutableStateFlow(false)
|
||||||
|
|
||||||
|
private val NEVER_UNDONE = MutableStateFlow(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a dropped event is still being written — false wherever moving is off.
|
||||||
|
* The drag overlays hold a landed block on its target for this window.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun moveInFlight(): Boolean {
|
||||||
|
val flow = LocalEventMove.current?.inFlight ?: NEVER_IN_FLIGHT
|
||||||
|
return flow.collectAsStateWithLifecycle().value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs [onUndo] when an undo write begins, and never for one that began before
|
||||||
|
* this composable came on screen.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun OnUndoStarted(onUndo: () -> Unit) {
|
||||||
|
val flow = LocalEventMove.current?.undoStarted ?: NEVER_UNDONE
|
||||||
|
val tick by flow.collectAsStateWithLifecycle()
|
||||||
|
var seen by remember { mutableIntStateOf(tick) }
|
||||||
|
LaunchedEffect(tick) {
|
||||||
|
if (tick == seen) return@LaunchedEffect
|
||||||
|
seen = tick
|
||||||
|
onUndo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opacity the source block keeps while its floating copy travels. */
|
||||||
|
const val GHOST_ALPHA: Float = 0.3f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opacity for a block whose copy is in flight: ghosted from the lift until the
|
||||||
|
* copy is handed back, then animated up rather than switched, so the block
|
||||||
|
* appears to travel to its new slot instead of vanishing and reappearing.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ghostAlpha(lifted: Boolean): Float = animateFloatAsState(
|
||||||
|
targetValue = if (lifted) GHOST_ALPHA else 1f,
|
||||||
|
label = "ghost-alpha",
|
||||||
|
).value
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [event] can be picked up and dragged — its calendar allows moving and
|
||||||
|
* the gesture is switched on. Not the same question as [eventMoveAction], which
|
||||||
|
* survives the setting.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun eventDragAllowed(event: EventInstance): Boolean {
|
||||||
|
val move = LocalEventMove.current ?: return false
|
||||||
|
return move.dragEnabled && move.allows(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A TalkBack action that opens [event] in the edit form, so rescheduling isn't
|
||||||
|
* pointer-only. Null when this event can't be moved.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun eventMoveAction(event: EventInstance): CustomAccessibilityAction? {
|
||||||
|
val move = LocalEventMove.current ?: return null
|
||||||
|
if (!move.allows(event)) return null
|
||||||
|
val label = stringResource(R.string.event_move_action)
|
||||||
|
return remember(event.instanceId, label, move) {
|
||||||
|
CustomAccessibilityAction(label) {
|
||||||
|
move.edit(event)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
|
||||||
|
/** Width of the hour gutter down the start edge of the day and week timelines. */
|
||||||
|
val GUTTER_WIDTH = 48.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start inset for the gutter's content (week badge + hour labels) so it centres
|
||||||
|
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp,
|
||||||
|
* matching the icon button's centre.
|
||||||
|
*/
|
||||||
|
val GUTTER_CONTENT_START_INSET = 8.dp
|
||||||
|
|
||||||
|
private val BADGE_HEIGHT = 20.dp
|
||||||
|
|
||||||
|
/** How far the fixed hour labels recede while a block is being dragged. */
|
||||||
|
private const val DIMMED_HOUR_ALPHA = 0.3f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The timeline's hour gutter. Scrolls in sync with the day columns through the
|
||||||
|
* shared [scrollState], and while [dragController] holds a lifted block it dims
|
||||||
|
* the hour labels and floats a badge with the drag's current start time at the
|
||||||
|
* row the block would land on.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun HourGutter(
|
||||||
|
scrollState: ScrollState,
|
||||||
|
hourHeight: Dp,
|
||||||
|
dragController: TimelineDragController,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
|
val locale = currentLocale()
|
||||||
|
// Derived: the drag is rewritten every frame, its snapped start once a slot.
|
||||||
|
val dragStartMin by remember(dragController) {
|
||||||
|
derivedStateOf { dragController.drag?.startMin }
|
||||||
|
}
|
||||||
|
val hourAlpha by animateFloatAsState(
|
||||||
|
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
|
||||||
|
label = "hourLabelAlpha",
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.width(GUTTER_WIDTH)
|
||||||
|
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
(0 until 24).forEach { h ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(hourHeight),
|
||||||
|
) {
|
||||||
|
if (h > 0) {
|
||||||
|
Text(
|
||||||
|
text = formatHourLabel(h, use24Hour, locale),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
.copy(alpha = hourAlpha),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.offset(y = (-6).dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dragStartMin?.let { startMin ->
|
||||||
|
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.offset(y = top)
|
||||||
|
.height(BADGE_HEIGHT)
|
||||||
|
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
// The dragged block already announces this time.
|
||||||
|
.clearAndSetSemantics { },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = formatGutterTime(startMin, use24Hour, locale),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -354,35 +354,66 @@ private fun CustomDaysEditor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snooze-duration picker, full-screen and **single-select**: the [presets]
|
* Duration picker, full-screen and **single-select**: the [presets] (whole-minute
|
||||||
* (whole-minute delays) each sit as a checkmark row, with a "Custom" row that
|
* lengths) each sit as a checkmark row, with a "Custom" row that expands an
|
||||||
* expands an inline amount field plus a Minutes/Hours unit toggle to enter an
|
* inline amount field plus a Minutes/Hours unit toggle to enter an arbitrary
|
||||||
* arbitrary delay. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking
|
* one. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking a preset or
|
||||||
* a preset or confirming a custom value applies via [onSelect] and closes.
|
* confirming a custom value applies via [onSelect] and closes. [label] renders a
|
||||||
* [label] renders a delay in minutes as a duration ("10 minutes", "1 hour") and
|
* length in minutes ("10 minutes", "1 hour") and is reused for both the rows and
|
||||||
* is reused for both the rows and the custom preview.
|
* the custom preview.
|
||||||
|
*
|
||||||
|
* [inheritLabel] adds an exclusive "use the default" row on top, for the
|
||||||
|
* per-calendar pickers; picking it reports null. [selected] is null exactly when
|
||||||
|
* that row is the current choice. [description] explains the setting above the
|
||||||
|
* rows when it needs it. [maxMinutes] caps what the custom editor will confirm,
|
||||||
|
* for settings that clamp their stored value — offering a length the store would
|
||||||
|
* silently shorten is worse than refusing it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SnoozeDurationPicker(
|
fun DurationPicker(
|
||||||
title: String,
|
title: String,
|
||||||
presets: List<Int>,
|
presets: List<Int>,
|
||||||
selected: Int,
|
selected: Int?,
|
||||||
label: @Composable (Int) -> String,
|
label: @Composable (Int) -> String,
|
||||||
onSelect: (Int) -> Unit,
|
onSelect: (Int?) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
description: String? = null,
|
||||||
|
inheritLabel: String? = null,
|
||||||
|
maxMinutes: Int = Int.MAX_VALUE,
|
||||||
) {
|
) {
|
||||||
val customSelected = selected !in presets
|
val inherits = selected == null
|
||||||
|
// The current choice when it isn't one of the presets — the Custom row then
|
||||||
|
// names it and the editor opens pre-filled with it.
|
||||||
|
val custom = selected?.takeIf { it !in presets }
|
||||||
val rowCount = presets.size + 1 // + the custom row
|
val rowCount = presets.size + 1 // + the custom row
|
||||||
|
|
||||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
var amountText by rememberSaveable {
|
var amountText by rememberSaveable {
|
||||||
mutableStateOf(if (customSelected) snoozeCustomAmount(selected).toString() else "")
|
mutableStateOf(custom?.let { durationCustomAmount(it).toString() }.orEmpty())
|
||||||
}
|
}
|
||||||
var unit by rememberSaveable {
|
var unit by rememberSaveable {
|
||||||
mutableStateOf(if (customSelected) snoozeCustomUnit(selected) else ReminderUnit.Minutes)
|
mutableStateOf(custom?.let { durationCustomUnit(it) } ?: ReminderUnit.Minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
||||||
|
if (description != null) PickerDescription(description)
|
||||||
|
if (inheritLabel != null) {
|
||||||
|
GroupedRow(
|
||||||
|
title = inheritLabel,
|
||||||
|
position = Position.Alone,
|
||||||
|
selected = inherits,
|
||||||
|
trailing = if (inherits) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onSelect(null)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
presets.forEachIndexed { index, minute ->
|
presets.forEachIndexed { index, minute ->
|
||||||
val isSelected = minute == selected
|
val isSelected = minute == selected
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
@@ -403,10 +434,10 @@ fun SnoozeDurationPicker(
|
|||||||
// The Custom row connects downward into the editor card when expanded, so
|
// The Custom row connects downward into the editor card when expanded, so
|
||||||
// the two read as one grouped container (the shared custom-expand pattern).
|
// the two read as one grouped container (the shared custom-expand pattern).
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = if (customSelected) label(selected) else stringResource(R.string.event_edit_reminder_custom),
|
title = custom?.let { label(it) } ?: stringResource(R.string.event_edit_reminder_custom),
|
||||||
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
|
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
|
||||||
selected = customSelected,
|
selected = custom != null,
|
||||||
trailing = if (customSelected) {
|
trailing = if (custom != null) {
|
||||||
{ SelectedCheck() }
|
{ SelectedCheck() }
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -418,12 +449,13 @@ fun SnoozeDurationPicker(
|
|||||||
enter = expandEnter(),
|
enter = expandEnter(),
|
||||||
exit = collapseExit(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
CustomSnoozeEditor(
|
CustomDurationEditor(
|
||||||
amountText = amountText,
|
amountText = amountText,
|
||||||
onAmountChange = { amountText = it },
|
onAmountChange = { amountText = it },
|
||||||
unit = unit,
|
unit = unit,
|
||||||
onUnitChange = { unit = it },
|
onUnitChange = { unit = it },
|
||||||
label = label,
|
label = label,
|
||||||
|
maxMinutes = maxMinutes,
|
||||||
onConfirm = { minutes ->
|
onConfirm = { minutes ->
|
||||||
onSelect(minutes)
|
onSelect(minutes)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
@@ -433,41 +465,48 @@ fun SnoozeDurationPicker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whole hours if the delay divides evenly, else minutes. */
|
/** Whole hours if the length divides evenly, else minutes. */
|
||||||
private fun snoozeCustomUnit(minutes: Int): ReminderUnit =
|
private fun durationCustomUnit(minutes: Int): ReminderUnit =
|
||||||
if (minutes % ReminderUnit.Hours.minutesFactor == 0) ReminderUnit.Hours else ReminderUnit.Minutes
|
if (minutes % ReminderUnit.Hours.minutesFactor == 0) ReminderUnit.Hours else ReminderUnit.Minutes
|
||||||
|
|
||||||
private fun snoozeCustomAmount(minutes: Int): Int =
|
private fun durationCustomAmount(minutes: Int): Int =
|
||||||
if (minutes % ReminderUnit.Hours.minutesFactor == 0) minutes / ReminderUnit.Hours.minutesFactor else minutes
|
if (minutes % ReminderUnit.Hours.minutesFactor == 0) minutes / ReminderUnit.Hours.minutesFactor else minutes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The expanded "Custom" snooze editor: a tonal card connected to the Custom row
|
* The expanded "Custom" duration editor: a tonal card connected to the Custom row
|
||||||
* above it. A Minutes/Hours unit toggle, an amount field with a live preview of
|
* above it. A Minutes/Hours unit toggle, an amount field with a live preview of
|
||||||
* the delay it resolves to, and a tonal confirm enabled only for a valid 1–999
|
* the length it resolves to, and a tonal confirm enabled only for a valid 1–999
|
||||||
* amount. [onConfirm] receives the final delay in minutes.
|
* amount that stays within [maxMinutes]. Over the cap, the preview names the
|
||||||
|
* limit instead of a length the setting could not keep. [onConfirm] receives the
|
||||||
|
* final length in minutes.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun CustomSnoozeEditor(
|
private fun CustomDurationEditor(
|
||||||
amountText: String,
|
amountText: String,
|
||||||
onAmountChange: (String) -> Unit,
|
onAmountChange: (String) -> Unit,
|
||||||
unit: ReminderUnit,
|
unit: ReminderUnit,
|
||||||
onUnitChange: (ReminderUnit) -> Unit,
|
onUnitChange: (ReminderUnit) -> Unit,
|
||||||
label: @Composable (Int) -> String,
|
label: @Composable (Int) -> String,
|
||||||
|
maxMinutes: Int,
|
||||||
onConfirm: (Int) -> Unit,
|
onConfirm: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
val units = remember { listOf(ReminderUnit.Minutes, ReminderUnit.Hours) }
|
val units = remember { listOf(ReminderUnit.Minutes, ReminderUnit.Hours) }
|
||||||
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
val entered = amountText.toIntOrNull()?.takeIf { it in 1..999 }?.times(unit.minutesFactor)
|
||||||
|
val amount = entered?.takeIf { it <= maxMinutes }
|
||||||
CustomAmountEditor(
|
CustomAmountEditor(
|
||||||
amountText = amountText,
|
amountText = amountText,
|
||||||
onAmountChange = onAmountChange,
|
onAmountChange = onAmountChange,
|
||||||
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
|
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
|
||||||
selectedUnit = units.indexOf(unit).coerceAtLeast(0),
|
selectedUnit = units.indexOf(unit).coerceAtLeast(0),
|
||||||
onUnitChange = { onUnitChange(units[it]) },
|
onUnitChange = { onUnitChange(units[it]) },
|
||||||
preview = amount?.let { label(it * unit.minutesFactor) }
|
preview = when {
|
||||||
?: stringResource(R.string.reminder_custom_amount),
|
amount != null -> label(amount)
|
||||||
|
entered != null -> stringResource(R.string.duration_custom_max, label(maxMinutes))
|
||||||
|
else -> stringResource(R.string.reminder_custom_amount)
|
||||||
|
},
|
||||||
setLabel = stringResource(R.string.reminder_custom_set),
|
setLabel = stringResource(R.string.reminder_custom_set),
|
||||||
confirmEnabled = amount != null,
|
confirmEnabled = amount != null,
|
||||||
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
onConfirm = { amount?.let(onConfirm) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand-in events for the view previews. A preview has no business querying the
|
||||||
|
* provider for a thumbnail, so every chooser renders the same invented week.
|
||||||
|
*
|
||||||
|
* Colours are raw ARGB on purpose: that is what the provider hands out for an
|
||||||
|
* event, so a theme token here would misrepresent what the views actually draw.
|
||||||
|
*/
|
||||||
|
private val SAMPLE_COLORS = listOf(
|
||||||
|
0xFF3F7BD4.toInt(),
|
||||||
|
0xFFCE5B4C.toInt(),
|
||||||
|
0xFF4E9A6A.toInt(),
|
||||||
|
0xFF8A63C7.toInt(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val SAMPLE_TITLES = listOf(
|
||||||
|
"Standup",
|
||||||
|
"Lunch",
|
||||||
|
"Review",
|
||||||
|
"Gym",
|
||||||
|
"Call",
|
||||||
|
"Workshop",
|
||||||
|
"Dentist",
|
||||||
|
"Trip",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Hands out [EventInstance]s with running ids and cycling sample titles/colours. */
|
||||||
|
private class SampleEvents(private val zone: TimeZone) {
|
||||||
|
private var id = 0L
|
||||||
|
|
||||||
|
fun timed(
|
||||||
|
day: LocalDate,
|
||||||
|
hour: Int,
|
||||||
|
minute: Int = 0,
|
||||||
|
lengthMinutes: Int,
|
||||||
|
colorIndex: Int,
|
||||||
|
): EventInstance {
|
||||||
|
val start = day.atTime(hour, minute).toInstant(zone)
|
||||||
|
return build(
|
||||||
|
start = start,
|
||||||
|
end = start + lengthMinutes.minutes,
|
||||||
|
isAllDay = false,
|
||||||
|
colorIndex = colorIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun allDay(from: LocalDate, days: Int, colorIndex: Int): EventInstance = build(
|
||||||
|
// All-day events sit at UTC midnights with an exclusive end.
|
||||||
|
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
end = from.plus(days, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
isAllDay = true,
|
||||||
|
colorIndex = colorIndex,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun build(
|
||||||
|
start: Instant,
|
||||||
|
end: Instant,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
colorIndex: Int,
|
||||||
|
): EventInstance {
|
||||||
|
val next = ++id
|
||||||
|
return EventInstance(
|
||||||
|
instanceId = next,
|
||||||
|
eventId = next,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = SAMPLE_TITLES[(next.toInt() - 1) % SAMPLE_TITLES.size],
|
||||||
|
start = start,
|
||||||
|
end = end,
|
||||||
|
isAllDay = isAllDay,
|
||||||
|
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A month's worth of stand-ins, spread so most weeks carry something, with one
|
||||||
|
* multi-day bar bridging cells and a busy today for the split pane.
|
||||||
|
*/
|
||||||
|
internal fun sampleMonthEvents(
|
||||||
|
firstOfMonth: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
|
): List<EventInstance> {
|
||||||
|
val sample = SampleEvents(zone)
|
||||||
|
return buildList {
|
||||||
|
add(sample.allDay(firstOfMonth.plus(9, DateTimeUnit.DAY), days = 3, colorIndex = 2))
|
||||||
|
add(sample.timed(firstOfMonth.plus(1, DateTimeUnit.DAY), 9, lengthMinutes = 60, colorIndex = 0))
|
||||||
|
add(sample.timed(firstOfMonth.plus(4, DateTimeUnit.DAY), 14, lengthMinutes = 120, colorIndex = 1))
|
||||||
|
add(sample.timed(firstOfMonth.plus(7, DateTimeUnit.DAY), 11, lengthMinutes = 60, colorIndex = 3))
|
||||||
|
add(sample.timed(firstOfMonth.plus(15, DateTimeUnit.DAY), 10, lengthMinutes = 60, colorIndex = 0))
|
||||||
|
add(sample.timed(firstOfMonth.plus(18, DateTimeUnit.DAY), 16, lengthMinutes = 60, colorIndex = 2))
|
||||||
|
add(sample.timed(firstOfMonth.plus(22, DateTimeUnit.DAY), 8, lengthMinutes = 120, colorIndex = 1))
|
||||||
|
add(sample.timed(firstOfMonth.plus(25, DateTimeUnit.DAY), 13, lengthMinutes = 60, colorIndex = 3))
|
||||||
|
// Today, so the split pane's list and the grid's dots both have content.
|
||||||
|
add(sample.timed(today, 9, lengthMinutes = 60, colorIndex = 0))
|
||||||
|
add(sample.timed(today, 12, lengthMinutes = 60, colorIndex = 1))
|
||||||
|
add(sample.timed(today, 15, lengthMinutes = 120, colorIndex = 3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand-ins for a timeline preview over [days]. [anchor] — the day the preview
|
||||||
|
* is centred on — gets the busy set, including one overlapping pair so the
|
||||||
|
* side-by-side lane layout shows; the rest of the week is sparser. A multi-day
|
||||||
|
* all-day bar is only added when there is more than one column to span.
|
||||||
|
*/
|
||||||
|
internal fun sampleTimelineEvents(
|
||||||
|
days: List<LocalDate>,
|
||||||
|
anchor: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
|
): List<EventInstance> {
|
||||||
|
val sample = SampleEvents(zone)
|
||||||
|
return buildList {
|
||||||
|
if (days.size > 1) {
|
||||||
|
add(sample.allDay(days[days.size / 2], days = 2, colorIndex = 2))
|
||||||
|
} else {
|
||||||
|
add(sample.allDay(anchor, days = 1, colorIndex = 2))
|
||||||
|
}
|
||||||
|
// The anchor day: enough to fill the column, with 09:30–11:00 running
|
||||||
|
// under 09:00–10:00 so the overlap resolves into two lanes.
|
||||||
|
add(sample.timed(anchor, 9, lengthMinutes = 60, colorIndex = 0))
|
||||||
|
add(sample.timed(anchor, 9, minute = 30, lengthMinutes = 90, colorIndex = 1))
|
||||||
|
add(sample.timed(anchor, 13, lengthMinutes = 120, colorIndex = 3))
|
||||||
|
days.filterNot { it == anchor }.forEachIndexed { index, day ->
|
||||||
|
// Skip every third day so the week doesn't read as a solid block.
|
||||||
|
if (index % 3 == 2) return@forEachIndexed
|
||||||
|
add(
|
||||||
|
sample.timed(
|
||||||
|
day = day,
|
||||||
|
hour = 8 + (index * 3) % 9,
|
||||||
|
lengthMinutes = if (index % 2 == 0) 60 else 90,
|
||||||
|
colorIndex = index,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand-ins for the agenda preview: a forward-looking fortnight where the first
|
||||||
|
* days are busy and later ones thin out, so the grouped-by-day list shows both
|
||||||
|
* a full day and the gaps between days.
|
||||||
|
*/
|
||||||
|
internal fun sampleAgendaEvents(today: LocalDate, zone: TimeZone): List<EventInstance> {
|
||||||
|
val sample = SampleEvents(zone)
|
||||||
|
return buildList {
|
||||||
|
add(sample.timed(today, 9, lengthMinutes = 60, colorIndex = 0))
|
||||||
|
add(sample.timed(today, 12, minute = 30, lengthMinutes = 60, colorIndex = 1))
|
||||||
|
add(sample.timed(today, 16, lengthMinutes = 90, colorIndex = 3))
|
||||||
|
val tomorrow = today.plus(1, DateTimeUnit.DAY)
|
||||||
|
add(sample.allDay(tomorrow, days = 2, colorIndex = 2))
|
||||||
|
add(sample.timed(tomorrow, 10, lengthMinutes = 120, colorIndex = 1))
|
||||||
|
add(sample.timed(today.plus(3, DateTimeUnit.DAY), 14, lengthMinutes = 60, colorIndex = 0))
|
||||||
|
add(sample.timed(today.plus(6, DateTimeUnit.DAY), 11, lengthMinutes = 60, colorIndex = 3))
|
||||||
|
add(sample.timed(today.plus(10, DateTimeUnit.DAY), 18, lengthMinutes = 120, colorIndex = 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.floret.components.OptionCard
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far a write to a recurring event should reach: this occurrence, it and
|
||||||
|
* everything after (a series split), or the whole series. Shared by the edit
|
||||||
|
* screen's save and a drag-and-drop reschedule; one of the two carve-outs from
|
||||||
|
* the full-screen picker rule.
|
||||||
|
*
|
||||||
|
* [allowOccurrence] drops "only this event" (an exception row can't carry its own
|
||||||
|
* rule); [allowSeries] drops the two wider options, for a rule whose days can't
|
||||||
|
* be recalculated from one moved occurrence — [reason] then says why.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RecurringScopeDialog(
|
||||||
|
title: String,
|
||||||
|
onSelect: (RecurringWriteScope) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
allowOccurrence: Boolean = true,
|
||||||
|
allowSeries: Boolean = true,
|
||||||
|
reason: String? = null,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(title) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
if (reason != null) {
|
||||||
|
Text(
|
||||||
|
text = reason,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (allowOccurrence) {
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_delete_option_occurrence),
|
||||||
|
onClick = { onSelect(RecurringWriteScope.ThisEvent) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (allowSeries) {
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_delete_option_following),
|
||||||
|
onClick = { onSelect(RecurringWriteScope.ThisAndFollowing) },
|
||||||
|
)
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_delete_option_series),
|
||||||
|
onClick = { onSelect(RecurringWriteScope.AllEvents) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -37,3 +37,21 @@ fun reminderLeadTimeLabel(minutes: Int): String = when {
|
|||||||
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
|
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
|
||||||
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Humanise a plain duration — no "before": "45 minutes", "8 hours", or both
|
||||||
|
* parts for a mixed length ("1 hour 30 minutes"). Shared by the snooze delay and
|
||||||
|
* the default event duration (#54).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun durationLabel(minutes: Int): String {
|
||||||
|
val hours = minutes / 60
|
||||||
|
val rest = minutes % 60
|
||||||
|
val hoursLabel = pluralStringResource(R.plurals.duration_hours, hours, hours)
|
||||||
|
val minutesLabel = pluralStringResource(R.plurals.duration_minutes, rest, rest)
|
||||||
|
return when {
|
||||||
|
hours == 0 -> minutesLabel
|
||||||
|
rest == 0 -> hoursLabel
|
||||||
|
else -> stringResource(R.string.duration_hours_minutes, hoursLabel, minutesLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,463 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||||
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.allowsEventMove
|
||||||
|
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||||
|
import de.jeanlucmakiola.calendula.domain.problems
|
||||||
|
import de.jeanlucmakiola.calendula.domain.realignRecurrence
|
||||||
|
import de.jeanlucmakiola.calendula.domain.resolvedZone
|
||||||
|
import de.jeanlucmakiola.calendula.domain.shiftedByDays
|
||||||
|
import de.jeanlucmakiola.calendula.domain.shiftedTo
|
||||||
|
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val MILLIS_PER_MINUTE = 60_000
|
||||||
|
private const val MINUTES_PER_DAY = 24 * 60
|
||||||
|
|
||||||
|
/** Where a dragged event should land. */
|
||||||
|
sealed interface MoveTarget {
|
||||||
|
/** A new start instant — a timeline drag, which moves time and day at once. */
|
||||||
|
data class Start(val instant: Instant) : MoveTarget
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A whole-day shift, keeping the time of day — a month-grid or all-day drag.
|
||||||
|
* A delta rather than a target date, so the screen and this view model can't
|
||||||
|
* disagree by a day over which zone the event's first day is resolved in.
|
||||||
|
*/
|
||||||
|
data class ByDays(val days: Int) : MoveTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One dropped event. [beginMillis]/[endMillis] are the dragged *occurrence's*
|
||||||
|
* own times (`Instances.BEGIN`/`END`), as the detail and edit screens pass them.
|
||||||
|
*/
|
||||||
|
data class MoveRequest(
|
||||||
|
val eventId: Long,
|
||||||
|
val beginMillis: Long,
|
||||||
|
val endMillis: Long,
|
||||||
|
val target: MoveTarget,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recurring drop waiting for the user to pick how far it reaches.
|
||||||
|
* [occurrenceOnly] means a wider write would leave the rule and the series
|
||||||
|
* anchor disagreeing — either the rule names days one moved occurrence can't
|
||||||
|
* re-derive (`BYDAY=MO,WE`, `2TH`, …), or the shift wouldn't carry the anchor
|
||||||
|
* across the same midnights the occurrence crossed.
|
||||||
|
*/
|
||||||
|
data class MoveScopePrompt(val occurrenceOnly: Boolean)
|
||||||
|
|
||||||
|
/** The inverse of a completed move, for the undo action. */
|
||||||
|
data class MoveUndo(
|
||||||
|
val eventId: Long,
|
||||||
|
/** The form as it now stands (the moved state). */
|
||||||
|
val moved: EventForm,
|
||||||
|
/** The form as it stood before the move. */
|
||||||
|
val restored: EventForm,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface MoveOutcome {
|
||||||
|
/**
|
||||||
|
* Written. [startMillis] is where the dragged occurrence now begins, for the
|
||||||
|
* confirmation message; [undo] is null when the write has no clean inverse.
|
||||||
|
*/
|
||||||
|
data class Moved(
|
||||||
|
val startMillis: Long,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
val undo: MoveUndo?,
|
||||||
|
) : MoveOutcome
|
||||||
|
|
||||||
|
data object Undone : MoveOutcome
|
||||||
|
|
||||||
|
/** `WRITE_CALENDAR` was revoked between the drop and the provider call. */
|
||||||
|
data object WriteDenied : MoveOutcome
|
||||||
|
|
||||||
|
/** The event vanished (sync, another device) between the read and the write. */
|
||||||
|
data object Gone : MoveOutcome
|
||||||
|
|
||||||
|
/** The drop would push the series past its own `UNTIL`, generating nothing. */
|
||||||
|
data object BlockedSeriesEnd : MoveOutcome
|
||||||
|
|
||||||
|
data object Failed : MoveOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a drag-and-drop reschedule (#68) through the same repository calls the
|
||||||
|
* edit screen's save uses, so recurring writes, reminder reconciliation and
|
||||||
|
* attendee preservation behave identically. Hosted at `CalendarHost` so one
|
||||||
|
* instance serves every calendar view and survives view switches. The full
|
||||||
|
* prefilled form is carried through the write — a stripped one would wipe the
|
||||||
|
* reminders and attendees the occurrence-exception path reconciles.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class RescheduleViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _scopePrompt = MutableStateFlow<MoveScopePrompt?>(null)
|
||||||
|
val scopePrompt: StateFlow<MoveScopePrompt?> = _scopePrompt.asStateFlow()
|
||||||
|
|
||||||
|
private val _outcome = MutableStateFlow<MoveOutcome?>(null)
|
||||||
|
val outcome: StateFlow<MoveOutcome?> = _outcome.asStateFlow()
|
||||||
|
|
||||||
|
private var pending: PreparedMove? = null
|
||||||
|
|
||||||
|
private val _inFlight = MutableStateFlow(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True from the moment a drop is accepted until its write settles — the
|
||||||
|
* window the dropped block holds its landing position for.
|
||||||
|
*/
|
||||||
|
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
|
||||||
|
|
||||||
|
private val _undoStarted = MutableStateFlow(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ticks the moment an undo write begins, before the provider has anything to
|
||||||
|
* re-read, so the view that drew the drop can carry its chip back rather than
|
||||||
|
* let it reappear on the old day.
|
||||||
|
*/
|
||||||
|
val undoStarted: StateFlow<Int> = _undoStarted.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set from the moment a drop is accepted until its write settles. Two drops
|
||||||
|
* of the same recurring event inside that window would compute their shift
|
||||||
|
* from the same pre-move occurrence, and the shifts would compound.
|
||||||
|
*/
|
||||||
|
private var busy = false
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
_inFlight.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendars whose events may be dragged. Nothing below the UI guards
|
||||||
|
* this, so a block outside this set registers no drag gesture at all.
|
||||||
|
*/
|
||||||
|
val movableCalendarIds: StateFlow<Set<Long>> = repository.calendars()
|
||||||
|
.map { calendars -> calendars.filter { it.allowsEventMove }.map { it.id }.toSet() }
|
||||||
|
.catch { emit(emptySet<Long>()) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Everything a drop needs, resolved before anything is written. */
|
||||||
|
private data class PreparedMove(
|
||||||
|
val request: MoveRequest,
|
||||||
|
val original: EventForm,
|
||||||
|
val updated: EventForm,
|
||||||
|
/** True for a series master with a rule — an exception row is not one. */
|
||||||
|
val isRecurring: Boolean,
|
||||||
|
/** False when the rule names days this move can't re-derive. */
|
||||||
|
val canRealign: Boolean,
|
||||||
|
/** The series row's DTSTART date after the move — what its `UNTIL` must clear. */
|
||||||
|
val newAnchorDate: LocalDate,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a drop, unless one is already being written. False means nothing will
|
||||||
|
* be written and the caller must let its held copy go now — waiting on
|
||||||
|
* [inFlight] would strand it until the settle timeout instead.
|
||||||
|
*/
|
||||||
|
fun move(request: MoveRequest): Boolean {
|
||||||
|
if (busy || _scopePrompt.value != null) return false
|
||||||
|
busy = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
val prepared = prepare(request)
|
||||||
|
if (prepared == null) {
|
||||||
|
busy = false
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
if (!prepared.isRecurring) {
|
||||||
|
write(prepared, RecurringWriteScope.AllEvents)
|
||||||
|
busy = false
|
||||||
|
} else {
|
||||||
|
// Still busy: the scope dialog is now the thing in flight.
|
||||||
|
pending = prepared
|
||||||
|
_scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answer the scope dialog. */
|
||||||
|
fun moveWithScope(scope: RecurringWriteScope) {
|
||||||
|
val prepared = pending ?: return
|
||||||
|
// Guard against the dialog ever offering a scope the rule can't carry.
|
||||||
|
if (!prepared.canRealign && scope != RecurringWriteScope.ThisEvent) return
|
||||||
|
pending = null
|
||||||
|
_scopePrompt.value = null
|
||||||
|
viewModelScope.launch {
|
||||||
|
write(prepared, scope)
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dismiss the scope dialog without writing. */
|
||||||
|
fun cancelScope() {
|
||||||
|
pending = null
|
||||||
|
busy = false
|
||||||
|
_scopePrompt.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put a completed move back where it came from. The outcome stands until the
|
||||||
|
* inverse write reports back, so the one chip changes what it says rather
|
||||||
|
* than closing and reopening. False when another write is already running —
|
||||||
|
* the chip hides its action for that window, so this is the backstop.
|
||||||
|
*/
|
||||||
|
fun undo(undo: MoveUndo): Boolean {
|
||||||
|
if (busy) return false
|
||||||
|
busy = true
|
||||||
|
_undoStarted.value += 1
|
||||||
|
viewModelScope.launch {
|
||||||
|
_outcome.value = try {
|
||||||
|
repository.updateEvent(undo.eventId, undo.moved, undo.restored)
|
||||||
|
MoveOutcome.Undone
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
MoveOutcome.WriteDenied
|
||||||
|
} catch (e: NoSuchEventException) {
|
||||||
|
MoveOutcome.Gone
|
||||||
|
} catch (e: Exception) {
|
||||||
|
MoveOutcome.Failed
|
||||||
|
}
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the outcome once the screen has shown it. */
|
||||||
|
fun consumeOutcome() {
|
||||||
|
_outcome.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun prepare(request: MoveRequest): PreparedMove? {
|
||||||
|
val detail = try {
|
||||||
|
repository.eventDetail(request.eventId)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: NoSuchEventException) {
|
||||||
|
_outcome.value = MoveOutcome.Gone
|
||||||
|
return null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_outcome.value = MoveOutcome.Failed
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val zone = TimeZone.currentSystemDefault()
|
||||||
|
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
|
||||||
|
val shifted = when (val target = request.target) {
|
||||||
|
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
|
||||||
|
is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
|
||||||
|
}
|
||||||
|
// A zero-distance drop is not a write.
|
||||||
|
if (shifted == original) return null
|
||||||
|
|
||||||
|
// The UNTIL check is deferred to the write, which knows how far the move
|
||||||
|
// reaches and so which date has to clear it.
|
||||||
|
if ((shifted.problems() - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
|
||||||
|
_outcome.value = MoveOutcome.Failed
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// An exception row stands alone whatever rule a sync adapter left on it.
|
||||||
|
val isRecurring = original.rrule != null && !detail.isException
|
||||||
|
val movedDay = shifted.start.date != original.start.date
|
||||||
|
// The series anchor moves by the same *wall-clock* shift as the dragged
|
||||||
|
// occurrence, so only a whole-day shift carries it across the same number
|
||||||
|
// of midnights whatever time of day it sits at. A same-date drag has its
|
||||||
|
// own, narrower check — see anchorKeepsItsDay.
|
||||||
|
val wholeDayShift = original.isAllDay || shifted.start.time == original.start.time
|
||||||
|
val realigned = if (isRecurring && movedDay) {
|
||||||
|
if (wholeDayShift) {
|
||||||
|
realignRecurrence(
|
||||||
|
requireNotNull(original.rrule),
|
||||||
|
original.start.date,
|
||||||
|
shifted.start.date,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
original.rrule
|
||||||
|
}
|
||||||
|
return PreparedMove(
|
||||||
|
request = request,
|
||||||
|
original = original,
|
||||||
|
// A rule we can't re-derive stays verbatim: the only scope offered
|
||||||
|
// then is the single occurrence, whose exception row carries no rule.
|
||||||
|
updated = shifted.copy(rrule = realigned ?: original.rrule),
|
||||||
|
isRecurring = isRecurring,
|
||||||
|
canRealign = when {
|
||||||
|
!isRecurring -> true
|
||||||
|
movedDay -> realigned != null
|
||||||
|
else -> anchorKeepsItsDay(detail, original, shifted, zone)
|
||||||
|
},
|
||||||
|
// Where the series row's own DTSTART lands, given the anchor moves by
|
||||||
|
// the same shift. Only read under canRealign, which is what
|
||||||
|
// guarantees the anchor really does travel this many days.
|
||||||
|
newAnchorDate = anchorDate(detail, original, zone)
|
||||||
|
.plus(shifted.start.date.toEpochDays() - original.start.date.toEpochDays(), DateTimeUnit.DAY),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope) {
|
||||||
|
val request = prepared.request
|
||||||
|
if (endsBeforeItStarts(prepared, scope)) {
|
||||||
|
_outcome.value = MoveOutcome.BlockedSeriesEnd
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_outcome.value = try {
|
||||||
|
when {
|
||||||
|
!prepared.isRecurring || scope == RecurringWriteScope.AllEvents ->
|
||||||
|
repository.updateEvent(request.eventId, prepared.original, prepared.updated)
|
||||||
|
|
||||||
|
scope == RecurringWriteScope.ThisEvent ->
|
||||||
|
repository.updateOccurrence(
|
||||||
|
request.eventId,
|
||||||
|
request.beginMillis,
|
||||||
|
prepared.updated,
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> repository.updateEventFromOccurrence(
|
||||||
|
eventId = request.eventId,
|
||||||
|
beginMillis = request.beginMillis,
|
||||||
|
original = prepared.original,
|
||||||
|
updated = prepared.updated,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MoveOutcome.Moved(
|
||||||
|
startMillis = movedStartMillis(prepared),
|
||||||
|
isAllDay = prepared.updated.isAllDay,
|
||||||
|
undo = undoFor(prepared, scope),
|
||||||
|
)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
MoveOutcome.WriteDenied
|
||||||
|
} catch (e: NoSuchEventException) {
|
||||||
|
MoveOutcome.Gone
|
||||||
|
} catch (e: Exception) {
|
||||||
|
MoveOutcome.Failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this write would leave a rule whose `UNTIL` precedes the first day
|
||||||
|
* it now applies to — the provider then generates nothing at all. Which date
|
||||||
|
* has to clear `UNTIL` depends on the scope: a whole-series move carries the
|
||||||
|
* series *anchor*, a split starts at the moved occurrence, and a single
|
||||||
|
* occurrence becomes an exception row that no `UNTIL` constrains.
|
||||||
|
*/
|
||||||
|
private fun endsBeforeItStarts(prepared: PreparedMove, scope: RecurringWriteScope): Boolean {
|
||||||
|
if (!prepared.isRecurring || scope == RecurringWriteScope.ThisEvent) return false
|
||||||
|
val rule = prepared.updated.rrule ?: return false
|
||||||
|
val end = parseSimpleRecurrence(rule)?.end as? RecurrenceEnd.Until ?: return false
|
||||||
|
val firstDay = if (scope == RecurringWriteScope.AllEvents) {
|
||||||
|
prepared.newAnchorDate
|
||||||
|
} else {
|
||||||
|
prepared.updated.start.date
|
||||||
|
}
|
||||||
|
return end.date < firstDay
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a same-date drag leaves the series anchor on its own day too. The
|
||||||
|
* anchor moves by the same wall-clock delta as the occurrence, and normally
|
||||||
|
* shares its time of day — but a row with no `EVENT_TIMEZONE` resolves the
|
||||||
|
* two in zones that can sit a DST hour apart, so a near-midnight drag could
|
||||||
|
* carry the anchor across a midnight the occurrence never crossed and leave
|
||||||
|
* `BYDAY` naming the wrong weekday.
|
||||||
|
*/
|
||||||
|
private fun anchorKeepsItsDay(
|
||||||
|
detail: EventDetail,
|
||||||
|
original: EventForm,
|
||||||
|
shifted: EventForm,
|
||||||
|
zone: TimeZone,
|
||||||
|
): Boolean {
|
||||||
|
val anchorZone = if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone)
|
||||||
|
val anchorMinute = detail.instance.start.toLocalDateTime(anchorZone)
|
||||||
|
.time.toMillisecondOfDay() / MILLIS_PER_MINUTE
|
||||||
|
val delta = (
|
||||||
|
shifted.start.time.toMillisecondOfDay() - original.start.time.toMillisecondOfDay()
|
||||||
|
) / MILLIS_PER_MINUTE
|
||||||
|
return anchorMinute + delta in 0 until MINUTES_PER_DAY
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The series row's own start date — for a recurring master, `EventDetail`
|
||||||
|
* carries the row's DTSTART rather than the tapped occurrence's. Read in the
|
||||||
|
* same anchoring the write path uses: UTC for an all-day series.
|
||||||
|
*/
|
||||||
|
private fun anchorDate(detail: EventDetail, original: EventForm, zone: TimeZone): LocalDate =
|
||||||
|
detail.instance.start
|
||||||
|
.toLocalDateTime(if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone))
|
||||||
|
.date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo is offered only where the inverse is one symmetric write: a
|
||||||
|
* non-recurring event, and a whole-series move. "This event" leaves an
|
||||||
|
* exception row behind and "this and following" splits the series with an
|
||||||
|
* UNTIL truncation — neither is undone by shifting back.
|
||||||
|
*/
|
||||||
|
private fun undoFor(prepared: PreparedMove, scope: RecurringWriteScope): MoveUndo? =
|
||||||
|
if (!prepared.isRecurring || scope == RecurringWriteScope.AllEvents) {
|
||||||
|
MoveUndo(
|
||||||
|
eventId = prepared.request.eventId,
|
||||||
|
moved = prepared.updated,
|
||||||
|
restored = prepared.original,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the dragged occurrence now begins, in the same anchoring the views
|
||||||
|
* read dates back in: UTC midnight for an all-day event.
|
||||||
|
*/
|
||||||
|
private fun movedStartMillis(prepared: PreparedMove): Long {
|
||||||
|
val form = prepared.updated
|
||||||
|
return if (form.isAllDay) {
|
||||||
|
form.start.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
|
||||||
|
} else {
|
||||||
|
form.start.toInstant(form.resolvedZone(TimeZone.currentSystemDefault()))
|
||||||
|
.toEpochMilliseconds()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ val LocalUse24HourFormat = staticCompositionLocalOf { true }
|
|||||||
private const val PATTERN_24 = "HH:mm"
|
private const val PATTERN_24 = "HH:mm"
|
||||||
private const val PATTERN_12 = "h:mm a"
|
private const val PATTERN_12 = "h:mm a"
|
||||||
private const val HOUR_PATTERN_12 = "h a"
|
private const val HOUR_PATTERN_12 = "h a"
|
||||||
|
private const val HOUR_MINUTE_PATTERN_12 = "h:mm"
|
||||||
|
|
||||||
/** A time-of-day [DateTimeFormatter] for the resolved convention and [locale]. */
|
/** A time-of-day [DateTimeFormatter] for the resolved convention and [locale]. */
|
||||||
fun timeOfDayFormatter(is24Hour: Boolean, locale: Locale): DateTimeFormatter =
|
fun timeOfDayFormatter(is24Hour: Boolean, locale: Locale): DateTimeFormatter =
|
||||||
@@ -42,6 +43,18 @@ fun formatMinuteOfDay(minutes: Int, is24Hour: Boolean, locale: Locale): String =
|
|||||||
else -> formatTimeOfDay(minutes / 60, minutes % 60, is24Hour, locale)
|
else -> formatTimeOfDay(minutes / 60, minutes % 60, is24Hour, locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time shown in the timeline gutter while a block is dragged: 24h →
|
||||||
|
* "09:15", 12h → "9:15". The meridiem is dropped — the hour labels around it
|
||||||
|
* already carry it, and the gutter is too narrow.
|
||||||
|
*/
|
||||||
|
fun formatGutterTime(minutes: Int, is24Hour: Boolean, locale: Locale): String {
|
||||||
|
val clamped = minutes.coerceIn(0, MINUTES_PER_DAY - 1)
|
||||||
|
val pattern = if (is24Hour) PATTERN_24 else HOUR_MINUTE_PATTERN_12
|
||||||
|
return LocalTime.of(clamped / 60, clamped % 60)
|
||||||
|
.format(DateTimeFormatter.ofPattern(pattern, locale))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The compact hour-only label for a timeline gutter: 24h → "13" (zero-padded,
|
* The compact hour-only label for a timeline gutter: 24h → "13" (zero-padded,
|
||||||
* the prior look); 12h → "1 PM".
|
* the prior look); 12h → "1 PM".
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
|
import androidx.compose.foundation.MutatePriority
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.absoluteOffset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
|
import androidx.compose.ui.layout.boundsInRoot
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||||
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** Start granularity a dragged block snaps to, matching the big calendar apps. */
|
||||||
|
const val DRAG_SNAP_MINUTES: Int = 15
|
||||||
|
|
||||||
|
/** How close to a timeline edge the finger must get before the view scrolls. */
|
||||||
|
private val AUTO_SCROLL_EDGE = 64.dp
|
||||||
|
|
||||||
|
/** Fastest auto-scroll step, per frame, right at the edge. */
|
||||||
|
private val AUTO_SCROLL_STEP = 16.dp
|
||||||
|
|
||||||
|
/** A block being dragged, in root coordinates so it can be drawn in an overlay. */
|
||||||
|
data class TimelineDrag(
|
||||||
|
val event: EventInstance,
|
||||||
|
val date: LocalDate,
|
||||||
|
val startMin: Int,
|
||||||
|
val endMin: Int,
|
||||||
|
val topLeftInRoot: Offset,
|
||||||
|
val sizePx: IntSize,
|
||||||
|
) {
|
||||||
|
/** What the snap haptics key off: one tick per changed slot, not per frame. */
|
||||||
|
val slot: Pair<LocalDate, Int> get() = date to startMin
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a finished drag asks its event to go. */
|
||||||
|
data class TimelineDrop(val event: EventInstance, val date: LocalDate, val startMin: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The timeline's live geometry, republished on every layout. Plain fields rather
|
||||||
|
* than snapshot state: it changes on every scroll frame, and the drag loop reads
|
||||||
|
* it per frame anyway.
|
||||||
|
*/
|
||||||
|
class TimelineGeometry {
|
||||||
|
/** The day-columns row — scrolling *content*, so its root position folds in the scroll. */
|
||||||
|
var grid: LayoutCoordinates? = null
|
||||||
|
|
||||||
|
/** The scroll viewport, for the edge that triggers auto-scrolling. */
|
||||||
|
var viewport: LayoutCoordinates? = null
|
||||||
|
var scroll: ScrollState? = null
|
||||||
|
var hourPx: Float = 0f
|
||||||
|
|
||||||
|
/** Column pitch — one column plus the gap after it. */
|
||||||
|
var columnWidthPx: Float = 0f
|
||||||
|
var columnGapPx: Float = 0f
|
||||||
|
var edgePx: Float = 0f
|
||||||
|
var stepPx: Float = 0f
|
||||||
|
var days: List<LocalDate> = emptyList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the columns are laid out right-to-left. Pointer coordinates are
|
||||||
|
* never mirrored but the grid is, so the mapping has to flip with it.
|
||||||
|
*/
|
||||||
|
var isRtl: Boolean = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hoisted drag state for one timeline (#68). It lives above the per-page
|
||||||
|
* `AnimatedContent`, so a page change mid-drag can't strand a ghost, and the
|
||||||
|
* block it renders is drawn in an overlay, clear of the day column's clip.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class TimelineDragController {
|
||||||
|
val geometry = TimelineGeometry()
|
||||||
|
|
||||||
|
var drag: TimelineDrag? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dropped block, held at the slot it landed on while the write runs — the
|
||||||
|
* grid behind it still shows the old time until the query re-reads.
|
||||||
|
*/
|
||||||
|
var settling: TimelineDrag? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which block is lifted, and whether anything is. Separate snapshot state
|
||||||
|
* from [drag], which changes every frame, so blocks that only need "am I the
|
||||||
|
* ghost" don't recompose that often. Stays set through [settling].
|
||||||
|
*/
|
||||||
|
var liftedInstanceId: Long? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the settled drop came from, and which event row it belongs to. The
|
||||||
|
* instance id alone can't identify the source block for the length of the
|
||||||
|
* write: the provider regenerates `Instances` rows, so a re-read carrying the
|
||||||
|
* *old* time can arrive under a new instance id.
|
||||||
|
*/
|
||||||
|
private var settledOrigin: Triple<Long, LocalDate, Int>? by mutableStateOf(null)
|
||||||
|
|
||||||
|
/** Whether a finger is on a block right now — [settling] is not dragging. */
|
||||||
|
var isDragging: Boolean by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the grid itself now draws the settled drop at its landing slot.
|
||||||
|
* The copy may only be handed back once this is true, or the source ghost
|
||||||
|
* flashes back to full opacity in its old slot.
|
||||||
|
*/
|
||||||
|
var settledOnGrid: Boolean by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var source: TimedBlock? = null
|
||||||
|
private var grab = Offset.Zero
|
||||||
|
private var pointer = Offset.Zero
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The slot the block already occupied when it was picked up — not the same
|
||||||
|
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
|
||||||
|
*/
|
||||||
|
private var originSlot: Pair<LocalDate, Int>? = null
|
||||||
|
|
||||||
|
fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) {
|
||||||
|
source = block
|
||||||
|
settling = null
|
||||||
|
isDragging = true
|
||||||
|
liftedInstanceId = block.event.instanceId
|
||||||
|
grab = pointerInRoot - blockInRoot
|
||||||
|
pointer = pointerInRoot
|
||||||
|
originSlot = null
|
||||||
|
recompute()
|
||||||
|
originSlot = drag?.slot
|
||||||
|
}
|
||||||
|
|
||||||
|
fun move(pointerInRoot: Offset) {
|
||||||
|
pointer = pointerInRoot
|
||||||
|
recompute()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
source = null
|
||||||
|
isDragging = false
|
||||||
|
liftedInstanceId = null
|
||||||
|
originSlot = null
|
||||||
|
drag = null
|
||||||
|
settling = null
|
||||||
|
settledOnGrid = false
|
||||||
|
settledOrigin = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [block] is the one whose copy is in flight, and so must stay a
|
||||||
|
* ghost: the instance the finger picked up, or once dropped whatever now
|
||||||
|
* sits in the slot it left.
|
||||||
|
*/
|
||||||
|
fun ghosts(block: TimedBlock, date: LocalDate): Boolean {
|
||||||
|
if (liftedInstanceId == null) return false
|
||||||
|
if (block.event.instanceId == liftedInstanceId) return true
|
||||||
|
val (eventId, originDate, originMin) = settledOrigin ?: return false
|
||||||
|
return block.event.eventId == eventId &&
|
||||||
|
date == originDate &&
|
||||||
|
block.startMin == originMin
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a day column now holds, so a settled drop can tell when the grid has
|
||||||
|
* caught up with it. Matched on the landing slot plus either the event row
|
||||||
|
* or its title, since a single-occurrence move writes a new `eventId`.
|
||||||
|
*/
|
||||||
|
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
|
||||||
|
val landed = settling ?: return
|
||||||
|
if (settledOnGrid || landed.date != date) return
|
||||||
|
settledOnGrid = blocks.any { block ->
|
||||||
|
block.startMin == landed.startMin &&
|
||||||
|
(
|
||||||
|
block.event.eventId == landed.event.eventId ||
|
||||||
|
block.event.title == landed.event.title
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End the drag, handing back where it landed — null when it never resolved,
|
||||||
|
* or when it landed back on the slot it started from. A real drop keeps its
|
||||||
|
* copy on the target as [settling] until [release].
|
||||||
|
*/
|
||||||
|
fun finish(): TimelineDrop? {
|
||||||
|
val landed = drag
|
||||||
|
val origin = originSlot
|
||||||
|
cancel()
|
||||||
|
if (landed == null || landed.slot == origin) return null
|
||||||
|
settling = landed
|
||||||
|
liftedInstanceId = landed.event.instanceId
|
||||||
|
settledOrigin = origin?.let { (date, min) -> Triple(landed.event.eventId, date, min) }
|
||||||
|
return TimelineDrop(landed.event, landed.date, landed.startMin)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop ghosting the source — the grid draws the drop itself by now — while
|
||||||
|
* the copy is still dissolving. Un-ghosting at [release] instead would leave
|
||||||
|
* the block dim under the fading copy and then brighten it: a visible dip.
|
||||||
|
*/
|
||||||
|
fun handOver() {
|
||||||
|
liftedInstanceId = null
|
||||||
|
settledOrigin = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the copy, once it has faded into the grid's own block. */
|
||||||
|
fun release() {
|
||||||
|
settling = null
|
||||||
|
settledOnGrid = false
|
||||||
|
handOver()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-derive the target from the pointer's *root* position. Recomputed rather
|
||||||
|
* than accumulated from `positionChange()`, because a stationary finger emits
|
||||||
|
* no move events while auto-scroll walks the content underneath it.
|
||||||
|
*/
|
||||||
|
fun recompute() {
|
||||||
|
val block = source ?: return
|
||||||
|
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
|
||||||
|
val hourPx = geometry.hourPx
|
||||||
|
val columnPx = geometry.columnWidthPx
|
||||||
|
val days = geometry.days
|
||||||
|
if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return
|
||||||
|
|
||||||
|
val origin = grid.positionInRoot()
|
||||||
|
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
|
||||||
|
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
||||||
|
// Clamp the start into the target day; a tail past midnight is fine.
|
||||||
|
val startMin = snapped.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
|
||||||
|
// The event's own length, not the block's: TimedBlock.endMin is clipped
|
||||||
|
// at midnight, which would draw a 22:00–02:00 event as a two-hour copy.
|
||||||
|
val span = (block.event.end - block.event.start).inWholeMinutes.toInt().coerceAtLeast(0)
|
||||||
|
// The column the finger is over on screen, and the day that column shows.
|
||||||
|
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
|
||||||
|
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
|
||||||
|
val height = maxOf(span / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
|
||||||
|
drag = TimelineDrag(
|
||||||
|
event = block.event,
|
||||||
|
date = days[dayIndex],
|
||||||
|
startMin = startMin,
|
||||||
|
endMin = startMin + span,
|
||||||
|
topLeftInRoot = Offset(
|
||||||
|
x = origin.x + column * columnPx,
|
||||||
|
y = origin.y + startMin / 60f * hourPx,
|
||||||
|
),
|
||||||
|
sizePx = IntSize(
|
||||||
|
(columnPx - geometry.columnGapPx).roundToInt(),
|
||||||
|
height.roundToInt(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hold the scroll for the whole drag and nudge it once per frame while the
|
||||||
|
* finger sits near an edge. One `scroll` call, since a loop of
|
||||||
|
* `animateScrollBy` would re-acquire the mutex and cancel the last.
|
||||||
|
*/
|
||||||
|
suspend fun autoScroll() {
|
||||||
|
val scroll = geometry.scroll ?: return
|
||||||
|
scroll.scroll(MutatePriority.UserInput) {
|
||||||
|
while (true) {
|
||||||
|
withFrameNanos { }
|
||||||
|
recompute()
|
||||||
|
val step = edgeStep()
|
||||||
|
if (step != 0f) scrollBy(step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun edgeStep(): Float {
|
||||||
|
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return 0f
|
||||||
|
val edge = geometry.edgePx
|
||||||
|
if (edge <= 0f) return 0f
|
||||||
|
val bounds = viewport.boundsInRoot()
|
||||||
|
val fromTop = pointer.y - bounds.top
|
||||||
|
val fromBottom = bounds.bottom - pointer.y
|
||||||
|
return when {
|
||||||
|
fromTop < edge -> -(edge - fromTop) / edge * geometry.stepPx
|
||||||
|
fromBottom < edge -> (edge - fromBottom) / edge * geometry.stepPx
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberTimelineDragController(): TimelineDragController {
|
||||||
|
val controller = remember { TimelineDragController() }
|
||||||
|
val density = LocalDensity.current
|
||||||
|
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
|
||||||
|
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when [block] actually begins on [day] rather than being the tail of an
|
||||||
|
* event that started earlier. A clipped block's top edge is midnight, not the
|
||||||
|
* event's start, so dragging it would move the event to a time it never had.
|
||||||
|
*/
|
||||||
|
fun TimedBlock.beginsOn(day: LocalDate, zone: TimeZone): Boolean =
|
||||||
|
event.start.toLocalDateTime(zone).date == day
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instant a drop asks for, read in the zone the timeline is drawn in. A drop
|
||||||
|
* into a spring-forward gap has no instant of its own; `toInstant` resolves it
|
||||||
|
* forward by the missing hour, and the block visibly settles there.
|
||||||
|
*/
|
||||||
|
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
|
||||||
|
LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A beat after the grid draws the drop, so its own block is under way before the
|
||||||
|
* copy starts dissolving.
|
||||||
|
*/
|
||||||
|
const val SETTLE_GRACE_MILLIS: Long = 60L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long to wait for a grid that never confirms the drop — it landed on a day
|
||||||
|
* this timeline doesn't show, or the write failed. The copy has to go either way.
|
||||||
|
*/
|
||||||
|
private const val SETTLE_TIMEOUT_MILLIS = 900L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hand-over: the copy dissolves over this while the grid's own block slides
|
||||||
|
* in under it. Kept short, because a full-width copy sits over any neighbour it
|
||||||
|
* now shares a lane with until it is gone.
|
||||||
|
*/
|
||||||
|
const val SETTLE_FADE_MILLIS: Int = 250
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The floating block, drawn over the whole calendar so it is free of the day
|
||||||
|
* column's clip and of the scroll viewport's rounded corners.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
|
||||||
|
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
|
val locale = currentLocale()
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val moveInFlight = moveInFlight()
|
||||||
|
|
||||||
|
// Both live here rather than beside the controller: they read [drag], which
|
||||||
|
// changes every frame, and this composable is the one that is meant to.
|
||||||
|
DragSnapHaptics(controller.drag?.slot)
|
||||||
|
LaunchedEffect(controller.isDragging) {
|
||||||
|
if (controller.isDragging) controller.autoScroll()
|
||||||
|
}
|
||||||
|
// Hold the landed copy until the write is done — including the time a
|
||||||
|
// recurring drop's scope dialog is up — and then until the grid draws the
|
||||||
|
// drop itself.
|
||||||
|
var handingOver by remember(controller.settling) { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid) {
|
||||||
|
if (controller.settling == null || moveInFlight) return@LaunchedEffect
|
||||||
|
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
|
||||||
|
handingOver = true
|
||||||
|
controller.handOver()
|
||||||
|
delay(SETTLE_FADE_MILLIS.toLong())
|
||||||
|
controller.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
// A copy of a block still in the tree behind it; announcing it again
|
||||||
|
// would duplicate the event for the drag's duration.
|
||||||
|
.clearAndSetSemantics { }
|
||||||
|
.onGloballyPositioned { origin = it.positionInRoot() },
|
||||||
|
) {
|
||||||
|
val drag = controller.drag ?: controller.settling ?: return@Box
|
||||||
|
// Landed: the copy sinks back to the grid's own plane while the write runs.
|
||||||
|
val landed = controller.drag == null
|
||||||
|
val lift by animateFloatAsState(
|
||||||
|
targetValue = if (landed || reduceMotion) 0f else 1f,
|
||||||
|
label = "drag-lift",
|
||||||
|
)
|
||||||
|
val copyAlpha by animateFloatAsState(
|
||||||
|
targetValue = if (handingOver) 0f else 1f,
|
||||||
|
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||||
|
label = "drag-handover",
|
||||||
|
)
|
||||||
|
val fill = eventFill(drag.event.color, dark, soften)
|
||||||
|
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
|
// An end past midnight wraps rather than saturating, so a four-hour
|
||||||
|
// event dragged to 22:00 reads "22:00–02:00" and not "22:00–24:00".
|
||||||
|
val endMin = if (drag.endMin > MINUTES_PER_DAY) {
|
||||||
|
drag.endMin % MINUTES_PER_DAY
|
||||||
|
} else {
|
||||||
|
drag.endMin
|
||||||
|
}
|
||||||
|
val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}–" +
|
||||||
|
formatMinuteOfDay(endMin, use24Hour, locale)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
// Absolute: these are root coordinates, and the direction-aware
|
||||||
|
// offset would mirror them across the screen in an RTL layout.
|
||||||
|
.absoluteOffset {
|
||||||
|
IntOffset(
|
||||||
|
(drag.topLeftInRoot.x - origin.x).roundToInt(),
|
||||||
|
(drag.topLeftInRoot.y - origin.y).roundToInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.size(
|
||||||
|
width = with(density) { drag.sizePx.width.toDp() },
|
||||||
|
height = with(density) { drag.sizePx.height.toDp() },
|
||||||
|
)
|
||||||
|
.padding(horizontal = 1.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = 1f + 0.02f * lift
|
||||||
|
scaleY = 1f + 0.02f * lift
|
||||||
|
shadowElevation = 8.dp.toPx() * lift
|
||||||
|
alpha = copyAlpha
|
||||||
|
shape = RoundedCornerShape(4.dp)
|
||||||
|
clip = false
|
||||||
|
}
|
||||||
|
.background(fill, RoundedCornerShape(4.dp))
|
||||||
|
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How tall one hour is drawn in the week and day timelines (#56).
|
||||||
|
*
|
||||||
|
* One shared setting for both views: they are the same grid at different widths,
|
||||||
|
* and a scale that only applied to one of them would read as a bug.
|
||||||
|
*
|
||||||
|
* [FitDay] is the answer to the actual complaint behind the issue — on a tall
|
||||||
|
* phone the default scale shows about half a day, so a whole week can hide
|
||||||
|
* appointments below the fold. It derives the hour height from the timeline's
|
||||||
|
* own viewport instead of a fixed value.
|
||||||
|
*
|
||||||
|
* [Custom] is what a pinch on the timeline leaves behind. The presets are the
|
||||||
|
* settings-screen vocabulary; a pinch is a direct manipulation and should be
|
||||||
|
* able to land anywhere between them, so it gets its own case rather than
|
||||||
|
* snapping to the nearest named step.
|
||||||
|
*/
|
||||||
|
sealed interface TimelineScale {
|
||||||
|
|
||||||
|
/** Whole day in one screen: the hour height follows the viewport. */
|
||||||
|
data object FitDay : TimelineScale
|
||||||
|
|
||||||
|
/** Denser than the default, still a fixed height. */
|
||||||
|
data object Compact : TimelineScale
|
||||||
|
|
||||||
|
/** The historical 56dp scale. */
|
||||||
|
data object Regular : TimelineScale
|
||||||
|
|
||||||
|
/** Roomier blocks, more scrolling. */
|
||||||
|
data object Comfortable : TimelineScale
|
||||||
|
|
||||||
|
/** A height the user pinched to. Build it through [custom], which clamps. */
|
||||||
|
data class Custom(val hourHeight: Dp) : TimelineScale
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The named steps the settings picker offers, coarse to roomy. */
|
||||||
|
val presets: List<TimelineScale> = listOf(FitDay, Compact, Regular, Comfortable)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pinched hour height. The floor a pinch actually stops at depends on
|
||||||
|
* the viewport and is applied when the height is resolved (see
|
||||||
|
* [hourHeight]); this only holds a stored value to something sane.
|
||||||
|
*/
|
||||||
|
fun custom(hourHeight: Dp): Custom =
|
||||||
|
Custom(hourHeight.coerceIn(MIN_STORED_HOUR_HEIGHT, MAX_PINCH_HOUR_HEIGHT))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hour height for this scale. [viewportHeight] is the visible height of the
|
||||||
|
* scrolling timeline and is only consulted by [TimelineScale.FitDay].
|
||||||
|
*
|
||||||
|
* The fit-day result is clamped: below [FIT_DAY_MIN] the 24 gutter labels stop
|
||||||
|
* being legible, and above [FIT_DAY_MAX] a short landscape day would stretch its
|
||||||
|
* blocks absurdly. On a screen too short for the whole day the clamp wins and
|
||||||
|
* the timeline still scrolls a little — honest, rather than unreadable.
|
||||||
|
*
|
||||||
|
* A pinched height is held to [fillHourHeight] here and not only in the gesture,
|
||||||
|
* because the viewport can change under a height that was already stored: pinch
|
||||||
|
* all the way out in landscape and the same value would leave portrait with dead
|
||||||
|
* space under midnight.
|
||||||
|
*/
|
||||||
|
fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) {
|
||||||
|
TimelineScale.FitDay -> (viewportHeight / 24f).coerceIn(FIT_DAY_MIN, FIT_DAY_MAX)
|
||||||
|
TimelineScale.Compact -> 40.dp
|
||||||
|
TimelineScale.Regular -> 56.dp
|
||||||
|
TimelineScale.Comfortable -> 80.dp
|
||||||
|
is TimelineScale.Custom -> this.hourHeight
|
||||||
|
.coerceAtMost(MAX_PINCH_HOUR_HEIGHT)
|
||||||
|
.coerceAtLeast(fillHourHeight(viewportHeight))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The minute of the day a tap [offsetY] px down a timeline column means, snapped
|
||||||
|
* to the hour it landed in.
|
||||||
|
*
|
||||||
|
* [hourPx] must be the hour height the column is drawing at *now* — a tap
|
||||||
|
* detector that captured it when it was installed maps taps to a pre-pinch grid
|
||||||
|
* (#148). A zero or negative height (a viewport measured at nothing) has no grid
|
||||||
|
* to read, so it answers midnight rather than dividing by it.
|
||||||
|
*/
|
||||||
|
fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
|
||||||
|
if (hourPx <= 0f) 0 else (offsetY / hourPx).toInt().coerceIn(0, 23) * 60
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shortest an event block may render, as a fraction of an hour. Blocks keep a
|
||||||
|
* floor so a 15-minute event stays tappable, but the floor scales with the hour
|
||||||
|
* height — a fixed 24dp would swallow half an hour once zoomed out and make
|
||||||
|
* short events overlap their neighbours.
|
||||||
|
*/
|
||||||
|
const val MIN_EVENT_FRACTION = 26f / 60f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrowest an event block may be and still wrap its title over several lines.
|
||||||
|
*
|
||||||
|
* Wrapping is driven by the block's height, so a tall block on a lane-split
|
||||||
|
* column would otherwise stack two or three characters per line — "Da/ily",
|
||||||
|
* "Fa/rmer/s…" — which reads worse than one ellipsised line. A full week column
|
||||||
|
* clears this on any phone; a split one never does, while the day view's much
|
||||||
|
* wider columns keep wrapping even several lanes deep.
|
||||||
|
*/
|
||||||
|
val MIN_TITLE_WRAP_WIDTH = 36.dp
|
||||||
|
|
||||||
|
/** Smallest hour height [TimelineScale.FitDay] will resolve to. */
|
||||||
|
val FIT_DAY_MIN = 24.dp
|
||||||
|
|
||||||
|
/** Largest hour height [TimelineScale.FitDay] will resolve to. */
|
||||||
|
val FIT_DAY_MAX = 96.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hour height at which all 24 hours exactly fill [viewportHeight] — how far
|
||||||
|
* out a pinch can zoom.
|
||||||
|
*
|
||||||
|
* Below it there is no more day left to uncover, so the only thing shrinking
|
||||||
|
* further buys is empty space under midnight. Zooming out means "show me more of
|
||||||
|
* the day", and once the whole day is on screen that request is answered.
|
||||||
|
*/
|
||||||
|
fun fillHourHeight(viewportHeight: Dp): Dp = viewportHeight / 24f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ceiling for a pinch, deliberately far above [TimelineScale.Comfortable]: a
|
||||||
|
* pinch is a deliberate act, so someone zooming in on a single busy afternoon
|
||||||
|
* should be allowed to go further than any preset offers.
|
||||||
|
*/
|
||||||
|
val MAX_PINCH_HOUR_HEIGHT = 240.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floor for a *stored* pinched height, which only has to stay sane enough to be
|
||||||
|
* re-clamped against whatever viewport it is later shown in. The floor a pinch
|
||||||
|
* stops at is [fillHourHeight].
|
||||||
|
*/
|
||||||
|
private val MIN_STORED_HOUR_HEIGHT = 1.dp
|
||||||
|
|
||||||
|
@get:StringRes
|
||||||
|
val TimelineScale.labelRes: Int
|
||||||
|
get() = when (this) {
|
||||||
|
TimelineScale.FitDay -> R.string.timeline_scale_fit_day
|
||||||
|
TimelineScale.Compact -> R.string.timeline_scale_compact
|
||||||
|
TimelineScale.Regular -> R.string.timeline_scale_regular
|
||||||
|
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable
|
||||||
|
is TimelineScale.Custom -> R.string.timeline_scale_custom
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:StringRes
|
||||||
|
val TimelineScale.descriptionRes: Int
|
||||||
|
get() = when (this) {
|
||||||
|
TimelineScale.FitDay -> R.string.timeline_scale_fit_day_summary
|
||||||
|
TimelineScale.Compact -> R.string.timeline_scale_compact_summary
|
||||||
|
TimelineScale.Regular -> R.string.timeline_scale_regular_summary
|
||||||
|
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_summary
|
||||||
|
is TimelineScale.Custom -> R.string.timeline_scale_custom_summary
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks a stored custom height; the rest of the value is its dp. */
|
||||||
|
private const val CUSTOM_PREFIX = "custom:"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stored names for the presets. Spelled out rather than taken from `toString()`
|
||||||
|
* so R8 can't rename them out from under an existing install — and they match
|
||||||
|
* the enum names this used to be, so a value written before the pinch existed
|
||||||
|
* still reads back as the same preset.
|
||||||
|
*/
|
||||||
|
private val PRESET_NAMES: Map<TimelineScale, String> = mapOf(
|
||||||
|
TimelineScale.FitDay to "FitDay",
|
||||||
|
TimelineScale.Compact to "Compact",
|
||||||
|
TimelineScale.Regular to "Regular",
|
||||||
|
TimelineScale.Comfortable to "Comfortable",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialise for the `timeline_scale` preference; see [parseTimelineScale].
|
||||||
|
*
|
||||||
|
* Spelled out rather than an `else` into [PRESET_NAMES], so a preset added later
|
||||||
|
* without a stored name is a compile error here instead of a crash the first
|
||||||
|
* time someone picks it.
|
||||||
|
*/
|
||||||
|
fun TimelineScale.storageValue(): String = when (this) {
|
||||||
|
is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value
|
||||||
|
TimelineScale.FitDay,
|
||||||
|
TimelineScale.Compact,
|
||||||
|
TimelineScale.Regular,
|
||||||
|
TimelineScale.Comfortable,
|
||||||
|
-> PRESET_NAMES.getValue(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a stored scale. Anything unrecognised — a null key, a name from a
|
||||||
|
* future release, a truncated custom height — falls back to
|
||||||
|
* [TimelineScale.Regular] rather than throwing, like the other enum prefs.
|
||||||
|
*/
|
||||||
|
fun parseTimelineScale(stored: String?): TimelineScale = when {
|
||||||
|
stored == null -> TimelineScale.Regular
|
||||||
|
stored.startsWith(CUSTOM_PREFIX) -> stored.removePrefix(CUSTOM_PREFIX).toFloatOrNull()
|
||||||
|
?.let { TimelineScale.custom(it.dp) }
|
||||||
|
?: TimelineScale.Regular
|
||||||
|
else -> PRESET_NAMES.entries.firstOrNull { it.value == stored }?.key ?: TimelineScale.Regular
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.foundation.gestures.calculateCentroid
|
||||||
|
import androidx.compose.foundation.gestures.calculateZoom
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.ceil
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scale the timelines draw at right now: the stored preference, or whatever
|
||||||
|
* a pinch is currently doing to it (#56).
|
||||||
|
*
|
||||||
|
* A pinch changes the scale on every pointer frame, which the preference alone
|
||||||
|
* cannot carry — a DataStore round trip per frame would lag the gesture badly
|
||||||
|
* behind the fingers. So the live value lives here in composition state and only
|
||||||
|
* the settled result is written back, once, when the fingers lift.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class TimelineZoom(
|
||||||
|
initial: TimelineScale,
|
||||||
|
private val persist: (TimelineScale) -> Unit,
|
||||||
|
) {
|
||||||
|
/** What the week and day timelines should draw at. */
|
||||||
|
var scale: TimelineScale by mutableStateOf(initial)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether fingers are rescaling the timeline right now. Snapshot state, so
|
||||||
|
* anything that tweens off [scale] can stand down for the gesture: the
|
||||||
|
* height changes every pointer frame, and a spring would spend the whole
|
||||||
|
* pinch chasing a target that has already moved.
|
||||||
|
*/
|
||||||
|
var isPinching: Boolean by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a value that came from the preference. Ignored mid-pinch: the stored
|
||||||
|
* value is a frame or two behind the fingers there, and letting it land would
|
||||||
|
* snap the timeline back while the user is still pinching.
|
||||||
|
*/
|
||||||
|
fun adopt(stored: TimelineScale) {
|
||||||
|
if (!isPinching) scale = stored
|
||||||
|
}
|
||||||
|
|
||||||
|
fun beginPinch() {
|
||||||
|
isPinching = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pinchTo(hourHeight: Dp) {
|
||||||
|
scale = TimelineScale.custom(hourHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun endPinch() {
|
||||||
|
isPinching = false
|
||||||
|
persist(scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zoom the timelines read. Falls back to a detached instance — one shared
|
||||||
|
* lazy value, not one per read, or each read would hand out a fresh state — so a
|
||||||
|
* preview or test can render a timeline without the activity's provider.
|
||||||
|
*/
|
||||||
|
private val DetachedTimelineZoom by lazy { TimelineZoom(TimelineScale.Regular) {} }
|
||||||
|
|
||||||
|
val LocalTimelineZoom = staticCompositionLocalOf { DetachedTimelineZoom }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app-wide [TimelineZoom], seeded from [stored] and writing settled pinches
|
||||||
|
* back through [onPersist].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberTimelineZoom(
|
||||||
|
stored: TimelineScale,
|
||||||
|
onPersist: (TimelineScale) -> Unit,
|
||||||
|
): TimelineZoom {
|
||||||
|
val persist by rememberUpdatedState(onPersist)
|
||||||
|
val zoom = remember { TimelineZoom(stored) { persist(it) } }
|
||||||
|
// Picking a preset in Settings has to reach the timelines, and so does the
|
||||||
|
// stored value arriving after the first frame.
|
||||||
|
LaunchedEffect(stored) { zoom.adopt(stored) }
|
||||||
|
return zoom
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-finger pinch that rescales a 24-hour timeline (#56) — the gesture the
|
||||||
|
* issue actually asked for; the Settings presets stay as the accessible route to
|
||||||
|
* the same thing.
|
||||||
|
*
|
||||||
|
* Three gestures share this area, so the pinch is deliberately the fussiest
|
||||||
|
* about claiming it: nothing happens until a *second* finger is down and the
|
||||||
|
* spread has grown past [PINCH_SLOP], which leaves one-finger vertical scrolling
|
||||||
|
* and the horizontal week swipe untouched, and lets a two-finger drag still
|
||||||
|
* scroll. It watches on [PointerEventPass.Initial] because the scroll it has to
|
||||||
|
* outrank is a descendant — on the main pass the scroll would have consumed the
|
||||||
|
* drag before this ever saw it.
|
||||||
|
*
|
||||||
|
* [hourHeight] is the *resolved* height, so a pinch that starts from
|
||||||
|
* `FitDay` picks up where the viewport left it rather than jumping.
|
||||||
|
* [viewportHeight] sets how far out it can go — see [fillHourHeight].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberTimelinePinchZoom(
|
||||||
|
scrollState: ScrollState,
|
||||||
|
viewportHeight: Dp,
|
||||||
|
hourHeight: Dp,
|
||||||
|
zoom: TimelineZoom,
|
||||||
|
): Modifier {
|
||||||
|
// The gesture loop outlives any single composition, so it reads these
|
||||||
|
// through state handles rather than capturing what they were when it started.
|
||||||
|
val currentHourHeight = rememberUpdatedState(hourHeight)
|
||||||
|
val currentViewport = rememberUpdatedState(viewportHeight)
|
||||||
|
return Modifier.pointerInput(scrollState, zoom) {
|
||||||
|
awaitEachGesture {
|
||||||
|
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||||
|
var claimed = false
|
||||||
|
var slop = 1f
|
||||||
|
// Scroll the layout could not give us yet, carried to the next frame
|
||||||
|
// (see anchoredScroll).
|
||||||
|
var pending = 0f
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||||
|
if (event.changes.none { it.pressed }) break
|
||||||
|
if (event.changes.count { it.pressed } >= 2) {
|
||||||
|
val step = event.calculateZoom()
|
||||||
|
if (!claimed) {
|
||||||
|
slop *= step
|
||||||
|
if (abs(slop - 1f) >= PINCH_SLOP) {
|
||||||
|
claimed = true
|
||||||
|
zoom.beginPinch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (claimed) {
|
||||||
|
val old = currentHourHeight.value.toPx()
|
||||||
|
val new = pinchedHourHeightPx(
|
||||||
|
target = old * step,
|
||||||
|
fillPx = fillHourHeight(currentViewport.value).toPx(),
|
||||||
|
maxPx = MAX_PINCH_HOUR_HEIGHT.toPx(),
|
||||||
|
)
|
||||||
|
// Half a pixel, not equality: the height round-trips
|
||||||
|
// through dp and back, and an exact test would read
|
||||||
|
// the float noise that comes back as a scale change
|
||||||
|
// and feed the scroll a delta on every frame of a
|
||||||
|
// held pinch.
|
||||||
|
if (abs(new - old) >= 0.5f) {
|
||||||
|
val centroidY = event.calculateCentroid(useCurrent = true).y
|
||||||
|
pending += anchoredScroll(scrollState.value, centroidY, old, new) -
|
||||||
|
scrollState.value
|
||||||
|
zoom.pinchTo(new.toDp())
|
||||||
|
}
|
||||||
|
pending -= scrollState.dispatchRawDelta(pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Hold the gesture to the end once it has become a pinch:
|
||||||
|
// letting go the moment a finger lifts would turn the tail of
|
||||||
|
// a zoom into a scroll, and the taps underneath into a new event.
|
||||||
|
if (claimed) event.changes.forEach { if (it.pressed) it.consume() }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// In a finally because the gesture can also end by having its
|
||||||
|
// pointer node disposed mid-pinch — the timeline swapping out
|
||||||
|
// under the fingers. The zoom outlives that node, and a pinch
|
||||||
|
// left open would make it ignore every later Settings change.
|
||||||
|
if (claimed) zoom.endPinch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a pinch aiming at [target] px per hour actually lands: clamped between
|
||||||
|
* "the whole day fills the screen" ([fillPx]) and [maxPx], then rounded to a
|
||||||
|
* whole pixel.
|
||||||
|
*
|
||||||
|
* The rounding is not cosmetic. The hour gutter is 24 stacked boxes one hour
|
||||||
|
* tall, and each rounds its own height to whole pixels, while the hour lines and
|
||||||
|
* event blocks are drawn at the fractional height — so a height of 56.4px lays
|
||||||
|
* the labels out at 56px and leaves the 23:00 label ~9px above the line it
|
||||||
|
* names, jumping the whole column as a pinch drifts across each half pixel.
|
||||||
|
* Pinning the hour to whole pixels keeps every part of the timeline on one grid.
|
||||||
|
*
|
||||||
|
* The bounds themselves are pulled onto that grid too, each in the direction
|
||||||
|
* that keeps its own promise — up for the fill floor, so no dead space opens
|
||||||
|
* under midnight, down for the ceiling. A fractional bound would be a height the
|
||||||
|
* pinch can be held against but never actually land on, and the difference feeds
|
||||||
|
* the focal anchor a scroll correction on every frame the fingers sit still.
|
||||||
|
*/
|
||||||
|
internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float =
|
||||||
|
// Filling the viewport wins over the ceiling: on a screen tall enough for
|
||||||
|
// the two to disagree, dead space is the worse of the two failures.
|
||||||
|
target.roundToInt().toFloat()
|
||||||
|
.coerceAtMost(floor(maxPx))
|
||||||
|
.coerceAtLeast(ceil(fillPx))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scroll offset that keeps the moment under [centroidY] under it after the
|
||||||
|
* hour height changes from [oldHourPx] to [newHourPx].
|
||||||
|
*
|
||||||
|
* Anchoring on the fingers is what makes a zoom feel like the day is being
|
||||||
|
* stretched rather than replaced: without it, zooming in on the evening walks
|
||||||
|
* the evening off the bottom of the screen.
|
||||||
|
*
|
||||||
|
* The caller may not get all of this at once — the content only grows on the
|
||||||
|
* next layout pass, so scrolling further down than the *current* content allows
|
||||||
|
* is refused — which is why the shortfall is carried over and re-offered.
|
||||||
|
*/
|
||||||
|
internal fun anchoredScroll(
|
||||||
|
scroll: Int,
|
||||||
|
centroidY: Float,
|
||||||
|
oldHourPx: Float,
|
||||||
|
newHourPx: Float,
|
||||||
|
): Float = ((scroll + centroidY) / oldHourPx) * newHourPx - centroidY
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the fingers must spread or close before the pinch takes over. Small
|
||||||
|
* enough to feel immediate, wide enough that the two-finger scroll a user meant
|
||||||
|
* as a scroll stays one.
|
||||||
|
*/
|
||||||
|
private const val PINCH_SLOP = 0.08f
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.animation.core.snap
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.layout
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The viewport a view preview pretends to be: a phone's width by the height a
|
||||||
|
* calendar view gets under the top bar. Scaling every preview from one fixed
|
||||||
|
* height keeps them comparable — each shows the same slice of screen.
|
||||||
|
*/
|
||||||
|
internal val PREVIEW_VIEWPORT_HEIGHT = 440.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders [content] at a full phone viewport and shrinks the finished layout
|
||||||
|
* into a box [height] tall, for the settings and onboarding choosers. Callers
|
||||||
|
* pass the *real* view composables, so a preview cannot drift from what it
|
||||||
|
* depicts.
|
||||||
|
*
|
||||||
|
* The [layout] below ignores the incoming constraints so the view measures at a
|
||||||
|
* phone viewport, and a [graphicsLayer] scale shrinks the result.
|
||||||
|
* `Modifier.requiredSize` is the obvious alternative but *centres* content that
|
||||||
|
* overflows its constraints, leaving only the bottom-right corner in the clip.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ScaledViewPreview(
|
||||||
|
height: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||||
|
val scale = height.value / PREVIEW_VIEWPORT_HEIGHT.value
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.clipToBounds()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
// A preview is a picture, not a control: swallow touches before the
|
||||||
|
// view's own clickables see them, and give screen readers one label
|
||||||
|
// instead of six weeks of day cells.
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
awaitPointerEvent(PointerEventPass.Initial).changes
|
||||||
|
.forEach { it.consume() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clearAndSetSemantics { }
|
||||||
|
// Measure at a full phone viewport but report the scaled size, so the
|
||||||
|
// node occupies exactly what it draws.
|
||||||
|
.layout { measurable, _ ->
|
||||||
|
val fullWidth = screenWidth.roundToPx()
|
||||||
|
val fullHeight = PREVIEW_VIEWPORT_HEIGHT.roundToPx()
|
||||||
|
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
|
||||||
|
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
|
||||||
|
placeable.place(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
transformOrigin = TransformOrigin(0f, 0f)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxSize()) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stage every preview picker sets its preview on, cross-fading as [selected]
|
||||||
|
* changes. A preview draws the app's own surface, so a tonal plate gives it the
|
||||||
|
* edge it would otherwise lack on a chooser screen; the plate takes the width
|
||||||
|
* and outer radius of the grouped rows underneath so the two read as one stack.
|
||||||
|
*
|
||||||
|
* Only vertical spacing is the caller's to pass — the horizontal inset is the
|
||||||
|
* rows' own, and the alignment is the point.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun <T> ViewPreviewFrame(
|
||||||
|
selected: T,
|
||||||
|
label: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable (T) -> Unit,
|
||||||
|
) {
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = GroupedListInset)
|
||||||
|
.clip(FRAME_SHAPE)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||||
|
.padding(FRAME_INSET),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Crossfade(
|
||||||
|
targetState = selected,
|
||||||
|
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||||
|
label = label,
|
||||||
|
) { shown ->
|
||||||
|
// Each preview renders the real view at phone size and scales it
|
||||||
|
// down, so its own corners are square — the plate rounds them.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(PREVIEW_SHAPE)
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.clipToBounds(),
|
||||||
|
) {
|
||||||
|
content(shown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val FRAME_INSET = 8.dp
|
||||||
|
|
||||||
|
/** The grouped rows' own outer radius, so the stack shares one silhouette. */
|
||||||
|
private val FRAME_SHAPE = RoundedCornerShape(22.dp)
|
||||||
|
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||||
@@ -42,9 +42,11 @@ import androidx.compose.material3.rememberDrawerState
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.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.key
|
||||||
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
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -53,14 +55,19 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.customActions
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -68,12 +75,28 @@ 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.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
||||||
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.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.BlockTimeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.beginsOn
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.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
|
||||||
@@ -87,9 +110,16 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
|||||||
import de.jeanlucmakiola.floret.locale.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.LocalTimelineZoom
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
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
|
||||||
@@ -100,18 +130,11 @@ import kotlin.time.Clock
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private val HOUR_HEIGHT = 56.dp
|
|
||||||
private val GUTTER_WIDTH = 48.dp
|
|
||||||
/** Start inset for the gutter's hour labels so they centre on the top bar's
|
|
||||||
* hamburger: with a 48dp gutter, 8dp lands the centre at 28dp (the app bar's
|
|
||||||
* 4dp inset + 24dp half icon button), matching the week view. */
|
|
||||||
private val GUTTER_CONTENT_START_INSET = 8.dp
|
|
||||||
private val MIN_EVENT_HEIGHT = 24.dp
|
|
||||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
|
|
||||||
/** Total all-day strip height for the day (0 when there are no all-day events). */
|
/** Total all-day strip height for the day (0 when there are no all-day events). */
|
||||||
private fun DayUiState.Success.allDayStripHeight(): Dp {
|
internal fun DayUiState.Success.allDayStripHeight(): Dp {
|
||||||
if (allDay.isEmpty()) return 0.dp
|
if (allDay.isEmpty()) return 0.dp
|
||||||
val lanes = allDay.maxOf { it.lane } + 1
|
val lanes = allDay.maxOf { it.lane } + 1
|
||||||
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
||||||
@@ -195,6 +218,7 @@ fun DayScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = date,
|
currentDate = date,
|
||||||
|
drawerState = drawerState,
|
||||||
viewOrder = drawerViewOrder,
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
@@ -221,6 +245,7 @@ fun DayScreen(
|
|||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
|
onJumpToDate = jumpToDate,
|
||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
@@ -264,7 +289,6 @@ private fun DayContent(
|
|||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
@@ -275,11 +299,9 @@ private fun DayContent(
|
|||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||||
val maxV = scrollState.maxValue
|
// Half the scroll range *is* noon: the content spans a full 24 hours, so
|
||||||
val target = with(density) {
|
// centring the range centres midday at whatever hour height is in force.
|
||||||
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
|
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||||
}.coerceIn(0, maxV)
|
|
||||||
scrollState.scrollTo(target)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
||||||
@@ -295,9 +317,16 @@ private fun DayContent(
|
|||||||
// 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 = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||||
|
|
||||||
|
// Above the AnimatedContent: a page change mid-drag would strand the
|
||||||
|
// floating block inside the outgoing page.
|
||||||
|
val dragController = rememberTimelineDragController()
|
||||||
|
val move = LocalEventMove.current
|
||||||
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
|
|
||||||
|
Box(modifier = modifier) {
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = state,
|
targetState = state,
|
||||||
modifier = modifier.then(swipeModifier),
|
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
||||||
contentKey = { s ->
|
contentKey = { s ->
|
||||||
when (s) {
|
when (s) {
|
||||||
is DayUiState.Success -> "success-${s.date}"
|
is DayUiState.Success -> "success-${s.date}"
|
||||||
@@ -316,21 +345,39 @@ private fun DayContent(
|
|||||||
topSectionColor = topSectionColor,
|
topSectionColor = topSectionColor,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = allDayHeight,
|
allDayHeight = allDayHeight,
|
||||||
|
dragController = dragController,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
|
onDrop = { drop ->
|
||||||
|
val took = move?.move(
|
||||||
|
MoveRequest(
|
||||||
|
eventId = drop.event.eventId,
|
||||||
|
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||||
|
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||||
|
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Refused, so nothing will land: let the copy go now
|
||||||
|
// rather than hold it out for a settle that never comes.
|
||||||
|
if (took != true) dragController.release()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
TimelineDragOverlay(dragController)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DaySuccess(
|
internal fun DaySuccess(
|
||||||
state: DayUiState.Success,
|
state: DayUiState.Success,
|
||||||
topSectionColor: Color,
|
topSectionColor: Color,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
|
dragController: TimelineDragController,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// All-day strip collapses to nothing when the day has no all-day events,
|
// All-day strip collapses to nothing when the day has no all-day events,
|
||||||
@@ -349,8 +396,10 @@ private fun DaySuccess(
|
|||||||
Timeline(
|
Timeline(
|
||||||
state = state,
|
state = state,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
|
dragController = dragController,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
|
onDrop = onDrop,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,6 +413,7 @@ private fun DayTopBar(
|
|||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
showTodayButton: Boolean,
|
showTodayButton: Boolean,
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
@@ -371,9 +421,10 @@ private fun DayTopBar(
|
|||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
CalendarTitleButton(
|
||||||
text = formatDayTitle(date, locale, currentYear),
|
title = formatDayTitle(date, locale, currentYear),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
currentDate = date,
|
||||||
|
onJumpToDate = onJumpToDate,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -482,66 +533,73 @@ private fun AllDayBar(
|
|||||||
private fun Timeline(
|
private fun Timeline(
|
||||||
state: DayUiState.Success,
|
state: DayUiState.Success,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
|
dragController: TimelineDragController,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
) {
|
) {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
val zoom = LocalTimelineZoom.current
|
||||||
val locale = currentLocale()
|
val density = LocalDensity.current
|
||||||
|
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
|
||||||
|
// timeline's own viewport height, which is only known here — below the top
|
||||||
|
// bar, date header and all-day strip.
|
||||||
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val hourHeight = zoom.scale.hourHeight(maxHeight)
|
||||||
|
val totalHeight = hourHeight * 24
|
||||||
|
// The pinch sits on the Row, above both scroll viewports: it has to
|
||||||
|
// outrank the vertical scroll, and it does that by watching the initial
|
||||||
|
// pass, which only reaches it if it is their ancestor.
|
||||||
|
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
|
||||||
// Gutter and day column are two scroll viewports that SHARE one scroll
|
// Gutter and day column are two scroll viewports that SHARE one scroll
|
||||||
// state, so they stay perfectly aligned. The day-column viewport is a
|
// state, so they stay perfectly aligned. The day-column viewport is a
|
||||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||||
// soft corners are permanent at any scroll position.
|
// soft corners are permanent at any scroll position.
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
||||||
// Hour gutter (scrolls in sync with the day column). Start inset so the
|
// Hour gutter (scrolls in sync with the day column). Start inset so the
|
||||||
// labels centre on the top bar hamburger, matching the week view.
|
// labels centre on the top bar hamburger, matching the week view.
|
||||||
Column(
|
HourGutter(
|
||||||
modifier = Modifier
|
scrollState = scrollState,
|
||||||
.width(GUTTER_WIDTH)
|
hourHeight = hourHeight,
|
||||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
dragController = dragController,
|
||||||
.fillMaxHeight()
|
|
||||||
.verticalScroll(scrollState),
|
|
||||||
) {
|
|
||||||
(0 until 24).forEach { h ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(HOUR_HEIGHT),
|
|
||||||
) {
|
|
||||||
if (h > 0) {
|
|
||||||
Text(
|
|
||||||
text = formatHourLabel(h, use24Hour, locale),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.offset(y = (-6).dp),
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Day column: rounded, clipped scroll viewport (permanent corners).
|
// Day column: rounded, clipped scroll viewport (permanent corners).
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState)
|
||||||
|
.onGloballyPositioned { dragController.geometry.viewport = it },
|
||||||
) {
|
) {
|
||||||
DayColumnCard(
|
DayColumnCard(
|
||||||
blocks = state.timed,
|
blocks = state.timed,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
date = state.date,
|
date = state.date,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
|
hourHeight = hourHeight,
|
||||||
|
dragController = dragController,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
|
onDrop = onDrop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(totalHeight),
|
.height(totalHeight)
|
||||||
|
// The scrolling content itself, so its root position
|
||||||
|
// already folds in the scroll offset.
|
||||||
|
.onGloballyPositioned { coords ->
|
||||||
|
dragController.geometry.let {
|
||||||
|
it.grid = coords
|
||||||
|
it.scroll = scrollState
|
||||||
|
it.hourPx = with(density) { hourHeight.toPx() }
|
||||||
|
it.columnGapPx = 0f
|
||||||
|
it.columnWidthPx = coords.size.width.toFloat()
|
||||||
|
it.days = listOf(state.date)
|
||||||
|
it.isRtl = isRtl
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -554,13 +612,26 @@ private fun DayColumnCard(
|
|||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
|
hourHeight: Dp,
|
||||||
|
dragController: TimelineDragController,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
||||||
val showHourLines = LocalShowHourLines.current
|
val showHourLines = LocalShowHourLines.current
|
||||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||||
|
// Tells a settled drop when this column has caught up with it.
|
||||||
|
LaunchedEffect(blocks, dragController.settling) {
|
||||||
|
dragController.noteGrid(date, blocks)
|
||||||
|
}
|
||||||
|
// The tap detector outlives the composition that installed it — a pinch or a
|
||||||
|
// Settings change moves the hour height without restarting it — so it reads
|
||||||
|
// the height and the callback through state handles instead of capturing
|
||||||
|
// them, or taps land on the scale the column had before the zoom (#148).
|
||||||
|
val currentHourPx = rememberUpdatedState(hourPx)
|
||||||
|
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
|
||||||
Card(
|
Card(
|
||||||
// Plain rectangular column — the soft corners come from the outer
|
// Plain rectangular column — the soft corners come from the outer
|
||||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||||
@@ -581,31 +652,51 @@ private fun DayColumnCard(
|
|||||||
// only fires on the column background. Snaps to the tapped hour.
|
// only fires on the column background. Snaps to the tapped hour.
|
||||||
.pointerInput(date) {
|
.pointerInput(date) {
|
||||||
detectTapGestures { offset ->
|
detectTapGestures { offset ->
|
||||||
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
|
currentOnCreateAt.value(
|
||||||
onCreateAt(date, hour * 60)
|
date,
|
||||||
|
tappedMinuteOfDay(offset.y, currentHourPx.value),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
val colWidth = maxWidth
|
val colWidth = maxWidth
|
||||||
|
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
||||||
|
// Keyed by event, so a block that changes time or lane is the *same*
|
||||||
|
// composable afterwards and tweens there. The ordinal disambiguates
|
||||||
|
// a column holding two occurrences of one series.
|
||||||
|
val ordinals = mutableMapOf<Long, Int>()
|
||||||
blocks.forEach { block ->
|
blocks.forEach { block ->
|
||||||
|
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
||||||
|
key(block.event.eventId, ordinal) {
|
||||||
val laneWidth = colWidth / block.laneCount
|
val laneWidth = colWidth / block.laneCount
|
||||||
val top = HOUR_HEIGHT * (block.startMin / 60f)
|
val top = hourHeight * (block.startMin / 60f)
|
||||||
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f)
|
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||||
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight
|
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||||
|
val place = animatedBlockPlacement(
|
||||||
|
x = laneWidth * block.lane,
|
||||||
|
y = top,
|
||||||
|
width = laneWidth,
|
||||||
|
height = height,
|
||||||
|
)
|
||||||
EventBlock(
|
EventBlock(
|
||||||
block = block,
|
block = block,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
|
height = place.height,
|
||||||
|
date = date,
|
||||||
|
dragController = dragController,
|
||||||
onClick = { onEventClick(block.event) },
|
onClick = { onEventClick(block.event) },
|
||||||
|
onDrop = onDrop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(x = laneWidth * block.lane, y = top)
|
.offset(x = place.x, y = place.y)
|
||||||
.width(laneWidth)
|
.width(place.width)
|
||||||
.height(height)
|
.height(place.height)
|
||||||
.padding(horizontal = 1.dp),
|
.padding(horizontal = 1.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Current-time line, on top of the events, only on today's column.
|
// Current-time line, on top of the events, only on today's column.
|
||||||
if (date == today) {
|
if (date == today) {
|
||||||
NowLine(date = date, hourHeight = HOUR_HEIGHT)
|
NowLine(date = date, hourHeight = hourHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -615,7 +706,11 @@ private fun DayColumnCard(
|
|||||||
private fun EventBlock(
|
private fun EventBlock(
|
||||||
block: TimedBlock,
|
block: TimedBlock,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
|
height: Dp,
|
||||||
|
date: LocalDate,
|
||||||
|
dragController: TimelineDragController,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
@@ -623,17 +718,54 @@ private fun EventBlock(
|
|||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||||
minToHm(block.endMin, use24Hour, locale)
|
minToHm(block.endMin, use24Hour, locale)
|
||||||
val showTime = block.endMin - block.startMin >= 45
|
val density = LocalDensity.current
|
||||||
|
val titleLineHeight = with(density) {
|
||||||
|
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||||
|
}
|
||||||
|
val timeLineHeight = with(density) {
|
||||||
|
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||||
|
}
|
||||||
|
// What's left for text once the 2.dp top/bottom padding is paid for. A block
|
||||||
|
// that cannot afford both lines spends its space on the title, and one too
|
||||||
|
// short even for that drops the title rather than serving a sliced one.
|
||||||
|
val available = height - 4.dp
|
||||||
|
val showTime = block.endMin - block.startMin >= 45 &&
|
||||||
|
available >= titleLineHeight + timeLineHeight
|
||||||
|
val showTitle = available >= titleLineHeight
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
val fill = eventFill(block.event.color, dark, soften)
|
val fill = eventFill(block.event.color, dark, soften)
|
||||||
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
|
val moveAction = eventMoveAction(block.event)
|
||||||
|
// A block clipped at the top continues from the previous day: its top edge
|
||||||
|
// is midnight, not the event's start, so dragging it would invent a time.
|
||||||
|
val draggable = eventDragAllowed(block.event) && block.beginsOn(date, zone)
|
||||||
|
val dragModifier = rememberEventDragSource(
|
||||||
|
enabled = draggable,
|
||||||
|
key = block.event.instanceId,
|
||||||
|
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
|
||||||
|
onMove = dragController::move,
|
||||||
|
onDrop = { dragController.finish()?.let(onDrop) },
|
||||||
|
onCancel = dragController::cancel,
|
||||||
|
)
|
||||||
|
val lifted = draggable && dragController.ghosts(block, date)
|
||||||
|
val ghost = ghostAlpha(lifted)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
// The source stays put as a ghost while its floating copy travels.
|
||||||
|
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||||
.background(fill, RoundedCornerShape(4.dp))
|
.background(fill, RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
|
// After clickable, so it is the inner node and wins the main pass;
|
||||||
|
// the tap still works, since a drag consumes the up.
|
||||||
|
.then(dragModifier)
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = "$title, $timeLabel" },
|
.semantics {
|
||||||
|
contentDescription = "$title, $timeLabel"
|
||||||
|
if (moveAction != null) customActions = listOf(moveAction)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
|
if (showTitle) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
@@ -641,13 +773,11 @@ private fun EventBlock(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = eventInk(fill, alpha = 0.85f),
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
if (showTime) {
|
if (showTime) {
|
||||||
Text(
|
BlockTimeLabel(
|
||||||
text = timeLabel,
|
label = timeLabel,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
color = eventInk(fill, alpha = 0.6f),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -656,8 +786,12 @@ private fun EventBlock(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayLoading() {
|
private fun DayLoading() {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
val scale = LocalTimelineZoom.current.scale
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Same scale resolution as the loaded timeline, so the skeleton's column
|
||||||
|
// doesn't resize the moment the real day arrives.
|
||||||
|
val totalHeight = scale.hourHeight(maxHeight) * 24
|
||||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||||
Box(
|
Box(
|
||||||
@@ -668,6 +802,7 @@ private fun DayLoading() {
|
|||||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.day
|
||||||
|
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.sampleTimelineEvents
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A live, scaled-down Day view for the onboarding view chooser. Today's date is
|
||||||
|
* real; only the events are stand-ins.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun DayViewPreview(
|
||||||
|
height: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
|
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||||
|
val state = remember(today, zone) { sampleDayState(today, zone) }
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||||
|
// Half the scroll range is noon — the same centring the live view does.
|
||||||
|
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
ScaledViewPreview(height = height, modifier = modifier) {
|
||||||
|
DaySuccess(
|
||||||
|
state = state,
|
||||||
|
topSectionColor = MaterialTheme.colorScheme.surface,
|
||||||
|
scrollState = scrollState,
|
||||||
|
allDayHeight = state.allDayStripHeight(),
|
||||||
|
dragController = rememberTimelineDragController(),
|
||||||
|
onEventClick = {},
|
||||||
|
onCreateAt = { _, _ -> },
|
||||||
|
onDrop = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sample day state, laid out through the same helpers the live view uses. */
|
||||||
|
private fun sampleDayState(today: LocalDate, zone: TimeZone): DayUiState.Success {
|
||||||
|
val days = listOf(today)
|
||||||
|
val events = sampleTimelineEvents(days, today, zone)
|
||||||
|
return DayUiState.Success(
|
||||||
|
date = today,
|
||||||
|
today = today,
|
||||||
|
allDay = layoutAllDay(events.filter { it.isAllDay }, days, zone),
|
||||||
|
timed = layoutDay(events, today, zone),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -100,6 +100,7 @@ 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.icuTimeZoneRegion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
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.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
@@ -386,7 +387,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
val instance = detail.instance
|
val instance = detail.instance
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val locale = currentDetailLocale()
|
val locale = currentDetailLocale()
|
||||||
val accent = eventFill(instance.color, dark, LocalSoftenColors.current)
|
val accent = eventAccent(instance.color, dark, LocalSoftenColors.current)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|||||||
@@ -127,12 +127,14 @@ import de.jeanlucmakiola.floret.identity.collapseExit
|
|||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
import de.jeanlucmakiola.calendula.ui.common.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.TimeZonePickerDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
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.eventAccent
|
||||||
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
|
||||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||||
@@ -451,30 +453,11 @@ private fun SaveScopeDialog(
|
|||||||
onSelect: (RecurringWriteScope) -> Unit,
|
onSelect: (RecurringWriteScope) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
AlertDialog(
|
RecurringScopeDialog(
|
||||||
onDismissRequest = onDismiss,
|
title = stringResource(R.string.event_edit_recurring_title),
|
||||||
title = { Text(stringResource(R.string.event_edit_recurring_title)) },
|
onSelect = onSelect,
|
||||||
text = {
|
onDismiss = onDismiss,
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
allowOccurrence = !recurrenceChanged,
|
||||||
if (!recurrenceChanged) {
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_delete_option_occurrence),
|
|
||||||
onClick = { onSelect(RecurringWriteScope.ThisEvent) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_delete_option_following),
|
|
||||||
onClick = { onSelect(RecurringWriteScope.ThisAndFollowing) },
|
|
||||||
)
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_delete_option_series),
|
|
||||||
onClick = { onSelect(RecurringWriteScope.AllEvents) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,7 +546,7 @@ private fun EventEditContent(
|
|||||||
// The accent ties the form to the detail screen's design language: the
|
// The accent ties the form to the detail screen's design language: the
|
||||||
// bar under the title takes the target calendar's colour.
|
// bar under the title takes the target calendar's colour.
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
val accent = selectedCalendar?.let { eventFill(it.color, dark, soften) }
|
val accent = selectedCalendar?.let { eventAccent(it.color, dark, soften) }
|
||||||
?: MaterialTheme.colorScheme.primary
|
?: MaterialTheme.colorScheme.primary
|
||||||
val gap = 12.dp
|
val gap = 12.dp
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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.firstDayOfWeek
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration
|
||||||
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
|
||||||
@@ -51,28 +52,38 @@ import kotlinx.datetime.toInstant
|
|||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Duration.Companion.hours
|
import kotlin.time.Duration.Companion.minutes
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a prefilled [EventEditViewModel.openImported] form came from — the two
|
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
|
||||||
* sources want different reminder handling (#49).
|
* want different reminder handling (#49), and differ in whether they own the
|
||||||
|
* event's length ([ownsDuration], #54) — one that does is never restretched to the
|
||||||
|
* default-duration setting.
|
||||||
*/
|
*/
|
||||||
enum class ImportSource {
|
enum class ImportSource(internal val ownsDuration: Boolean) {
|
||||||
/**
|
/**
|
||||||
* An external `ACTION_INSERT` intent (another app/widget, e.g. Google Maps).
|
* An external `ACTION_INSERT` intent that named both ends (another app/widget,
|
||||||
* It carries no reminders of its own, so the settings default is applied
|
* e.g. Google Maps), or an in-app duplicate. It carries no reminders of its
|
||||||
* automatically, exactly like an in-app new event.
|
* own, so the settings default is applied automatically, exactly like an
|
||||||
|
* in-app new event; its span, though, is the caller's.
|
||||||
*/
|
*/
|
||||||
Insert,
|
Insert(ownsDuration = true),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An `ACTION_INSERT` that named only a start. Reminders behave as for
|
||||||
|
* [Insert], but the length is ours to pick, so the default-duration setting
|
||||||
|
* applies just like an in-app new event.
|
||||||
|
*/
|
||||||
|
InsertOpenEnded(ownsDuration = false),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A parsed single-event `.ics` file. Its own reminders are respected; the
|
* A parsed single-event `.ics` file. Its own reminders are respected; the
|
||||||
* settings default is offered through [EventEditViewModel.importReminderPrompt]
|
* settings default is offered through [EventEditViewModel.importReminderPrompt]
|
||||||
* rather than silently applied or suppressed.
|
* rather than silently applied or suppressed. Its span is the file's.
|
||||||
*/
|
*/
|
||||||
File,
|
File(ownsDuration = true),
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,6 +124,9 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// freezes the auto-applied default: switching calendars no longer overwrites
|
// freezes the auto-applied default: switching calendars no longer overwrites
|
||||||
// their choice. Reset with the form.
|
// their choice. Reset with the form.
|
||||||
private val _remindersTouched = MutableStateFlow(false)
|
private val _remindersTouched = MutableStateFlow(false)
|
||||||
|
// Same freeze for the default duration (#54): once the user has set an end
|
||||||
|
// time by hand, switching calendars no longer stretches the event.
|
||||||
|
private val _durationTouched = MutableStateFlow(false)
|
||||||
// A one-time offer, raised when a .ics import opens, to replace the file's
|
// A one-time offer, raised when a .ics import opens, to replace the file's
|
||||||
// reminders with the settings default (#49). Null while there's nothing to ask.
|
// reminders with the settings default (#49). Null while there's nothing to ask.
|
||||||
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
|
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
|
||||||
@@ -282,8 +296,10 @@ class EventEditViewModel @Inject constructor(
|
|||||||
* 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
|
||||||
* in the day/week grid; without it the default is the next full hour (today)
|
* in the day/week grid; without it the default is the next full hour (today)
|
||||||
* or 09:00 (any other day). No-op when a form is already open, so user input
|
* or 09:00 (any other day). The event opens an hour long and is stretched to
|
||||||
* survives configuration changes; [reset] clears it when the screen closes.
|
* the configured default by [applyDefaultDuration], like the reminders.
|
||||||
|
* No-op when a form is already open, so user input survives configuration
|
||||||
|
* changes; [reset] clears it when the screen closes.
|
||||||
*/
|
*/
|
||||||
fun openNew(date: LocalDate, startMinutes: Int? = null) {
|
fun openNew(date: LocalDate, startMinutes: Int? = null) {
|
||||||
if (_form.value != null) return
|
if (_form.value != null) return
|
||||||
@@ -300,9 +316,11 @@ class EventEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
else -> LocalDateTime(date, LocalTime(9, 0))
|
else -> LocalDateTime(date, LocalTime(9, 0))
|
||||||
}
|
}
|
||||||
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
val end = (start.toInstant(zone) + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes)
|
||||||
|
.toLocalDateTime(zone)
|
||||||
_form.value = EventForm(calendarId = null, start = start, end = end)
|
_form.value = EventForm(calendarId = null, start = start, end = end)
|
||||||
applyDefaultReminder()
|
applyDefaultReminder()
|
||||||
|
applyDefaultDuration()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -321,6 +339,12 @@ class EventEditViewModel @Inject constructor(
|
|||||||
* settings default is configured and differs, [importReminderPrompt] offers
|
* settings default is configured and differs, [importReminderPrompt] offers
|
||||||
* to swap it in rather than silently deciding for the user.
|
* to swap it in rather than silently deciding for the user.
|
||||||
*
|
*
|
||||||
|
* The length is frozen the same way: a source that named the event's end
|
||||||
|
* ([ImportSource.ownsDuration]) keeps it, so the default-duration setting
|
||||||
|
* never silently rewrites a span someone else chose; only an
|
||||||
|
* [ImportSource.InsertOpenEnded] intent — a start with no end — gets the
|
||||||
|
* default applied.
|
||||||
|
*
|
||||||
* No-op when a form is already open, so the prefill survives configuration
|
* No-op when a form is already open, so the prefill survives configuration
|
||||||
* changes.
|
* changes.
|
||||||
*/
|
*/
|
||||||
@@ -328,8 +352,9 @@ class EventEditViewModel @Inject constructor(
|
|||||||
if (_form.value != null || _editTarget.value != null) return
|
if (_form.value != null || _editTarget.value != null) return
|
||||||
_revealed.value = form.populatedFields()
|
_revealed.value = form.populatedFields()
|
||||||
_form.value = form
|
_form.value = form
|
||||||
|
if (source.ownsDuration) _durationTouched.value = true else applyDefaultDuration()
|
||||||
when (source) {
|
when (source) {
|
||||||
ImportSource.Insert ->
|
ImportSource.Insert, ImportSource.InsertOpenEnded ->
|
||||||
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
|
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
|
||||||
else applyDefaultReminder()
|
else applyDefaultReminder()
|
||||||
|
|
||||||
@@ -358,7 +383,9 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val form = _form.value ?: return@launch
|
val form = _form.value ?: return@launch
|
||||||
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||||
val reminders = defaults.resolveFor(targetId, form.isAllDay)
|
val reminders = defaults.resolveFor(targetId, form.isAllDay)
|
||||||
_form.value = form.copy(reminders = reminders)
|
// Write through update(): the duration default resolves in parallel,
|
||||||
|
// so both must compose onto the current form, not a pre-suspend copy.
|
||||||
|
update { it.copy(reminders = reminders) }
|
||||||
// Surface the section so an auto-applied default is visible and
|
// Surface the section so an auto-applied default is visible and
|
||||||
// removable, even when Reminders isn't a default-shown field.
|
// removable, even when Reminders isn't a default-shown field.
|
||||||
if (reminders.isNotEmpty()) {
|
if (reminders.isNotEmpty()) {
|
||||||
@@ -377,6 +404,39 @@ class EventEditViewModel @Inject constructor(
|
|||||||
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
||||||
}.first()
|
}.first()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stretch a new timed event to the configured default length (#54) — the
|
||||||
|
* resolved calendar's per-calendar duration, otherwise the global default —
|
||||||
|
* keeping its start put. No-op while editing an existing event, on an all-day
|
||||||
|
* event (which is date-anchored), or once the user has set an end time by
|
||||||
|
* hand. [calendarId] short-circuits the resolution after a calendar switch;
|
||||||
|
* null resolves it as the form does.
|
||||||
|
*/
|
||||||
|
private fun applyDefaultDuration(calendarId: Long? = null) {
|
||||||
|
if (_editTarget.value != null || _durationTouched.value) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val global = settingsPrefs.defaultEventDurationMinutes.first()
|
||||||
|
val overrides = settingsPrefs.perCalendarEventDuration.first()
|
||||||
|
// Only resolve the calendar when an override could actually apply —
|
||||||
|
// that read waits on the provider query, and the stretch would then
|
||||||
|
// land after the user can already see (and accept) the end time.
|
||||||
|
val targetId = when {
|
||||||
|
overrides.isEmpty() -> null
|
||||||
|
else -> calendarId ?: resolvedCalendarId.first()
|
||||||
|
}
|
||||||
|
// Re-check after suspending: bail if the form closed or the user edited.
|
||||||
|
if (_editTarget.value != null || _durationTouched.value) return@launch
|
||||||
|
if (_form.value?.isAllDay != false) return@launch
|
||||||
|
val duration = resolveDefaultEventDuration(global, overrides, targetId)
|
||||||
|
val zone = TimeZone.currentSystemDefault()
|
||||||
|
update { form ->
|
||||||
|
form.copy(
|
||||||
|
end = (form.start.toInstant(zone) + duration.minutes).toLocalDateTime(zone),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A `.ics` import respects the file's reminders, but an event opened from a
|
* A `.ics` import respects the file's reminders, but an event opened from a
|
||||||
* file often has none while the user still expects their configured default.
|
* file often has none while the user still expects their configured default.
|
||||||
@@ -455,6 +515,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_editTarget.value = null
|
_editTarget.value = null
|
||||||
_loadFailed.value = false
|
_loadFailed.value = false
|
||||||
_remindersTouched.value = false
|
_remindersTouched.value = false
|
||||||
|
_durationTouched.value = false
|
||||||
_importReminderPrompt.value = null
|
_importReminderPrompt.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,6 +540,9 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// 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()
|
||||||
|
// Coming back out of all-day re-applies the default length (guarded);
|
||||||
|
// going all-day is a no-op, since dates carry no duration.
|
||||||
|
applyDefaultDuration()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -500,9 +564,10 @@ class EventEditViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
fun setCalendar(id: Long) {
|
fun setCalendar(id: Long) {
|
||||||
update { it.copy(calendarId = id, colorKey = null, color = null) }
|
update { it.copy(calendarId = id, colorKey = null, color = null) }
|
||||||
// A fresh event re-inherits the new calendar's default reminder unless
|
// A fresh event re-inherits the new calendar's default reminder and
|
||||||
// the user has already hand-edited it (guarded inside).
|
// length unless the user has already hand-edited them (guarded inside).
|
||||||
applyDefaultReminder(id)
|
applyDefaultReminder(id)
|
||||||
|
applyDefaultDuration(id)
|
||||||
}
|
}
|
||||||
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
||||||
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
||||||
@@ -559,8 +624,18 @@ class EventEditViewModel @Inject constructor(
|
|||||||
/** Moving the start drags the end along, preserving the duration. */
|
/** Moving the start drags the end along, preserving the duration. */
|
||||||
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
|
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
|
||||||
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) }
|
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) }
|
||||||
fun setEndDate(date: LocalDate) = update { it.copy(end = LocalDateTime(date, it.end.time)) }
|
|
||||||
fun setEndTime(time: LocalTime) = update { it.copy(end = LocalDateTime(it.end.date, time)) }
|
// Setting an end by hand is the user picking a length: it freezes the
|
||||||
|
// default duration, so a later calendar switch keeps their span (#54).
|
||||||
|
fun setEndDate(date: LocalDate) {
|
||||||
|
_durationTouched.value = true
|
||||||
|
update { it.copy(end = LocalDateTime(date, it.end.time)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setEndTime(time: LocalTime) {
|
||||||
|
_durationTouched.value = true
|
||||||
|
update { it.copy(end = LocalDateTime(it.end.date, time)) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate and write. Saving a dirty recurring event pauses in
|
* Validate and write. Saving a dirty recurring event pauses in
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.sourceAppName
|
import de.jeanlucmakiola.calendula.ui.common.sourceAppName
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
|
||||||
@@ -73,7 +74,12 @@ private fun FilterList(
|
|||||||
},
|
},
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
modifier = Modifier.padding(
|
||||||
|
start = GroupedListInset,
|
||||||
|
end = GroupedListInset,
|
||||||
|
top = 12.dp,
|
||||||
|
bottom = 4.dp,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
group.calendars.forEachIndexed { index, cal ->
|
group.calendars.forEachIndexed { index, cal ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
@@ -103,7 +109,7 @@ private fun FilterLoading(modifier: Modifier = Modifier) {
|
|||||||
repeat(4) {
|
repeat(4) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 28.dp)
|
.padding(horizontal = GroupedListInset)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(36.dp)
|
.height(36.dp)
|
||||||
.background(
|
.background(
|
||||||
@@ -129,6 +135,6 @@ private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier)
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 28.dp, vertical = 24.dp),
|
.padding(horizontal = GroupedListInset, vertical = 24.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
|
import androidx.compose.ui.layout.boundsInRoot
|
||||||
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/** One week row's live geometry, republished on every layout while it is on screen. */
|
||||||
|
class MonthRowGeometry(
|
||||||
|
val days: List<LocalDate>,
|
||||||
|
/** The row's day-column box — the space chip offsets are measured in. */
|
||||||
|
val cell: LayoutCoordinates,
|
||||||
|
/** The lane band inside that box, where the chips themselves are seated. */
|
||||||
|
val band: LayoutCoordinates?,
|
||||||
|
val columnWidthPx: Float,
|
||||||
|
val laneHeightPx: Float,
|
||||||
|
val laneCount: Int,
|
||||||
|
/**
|
||||||
|
* Whether the columns are laid out right-to-left. Pointer coordinates are
|
||||||
|
* never mirrored but the grid is, so the mapping has to flip with it.
|
||||||
|
*/
|
||||||
|
val isRtl: Boolean,
|
||||||
|
/** What this row currently draws in [lane] of column `col`, or null. */
|
||||||
|
val chipAt: (col: Int, lane: Int) -> EventInstance?,
|
||||||
|
) {
|
||||||
|
/** Root top-left of the chip seated in [lane] of column [col]. */
|
||||||
|
fun seat(col: Int, lane: Int): Offset? {
|
||||||
|
val band = band?.takeIf { it.isAttached } ?: return null
|
||||||
|
val origin = band.positionInRoot()
|
||||||
|
val column = if (isRtl) days.lastIndex - col else col
|
||||||
|
return Offset(origin.x + column * columnWidthPx, origin.y + lane * laneHeightPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where this row seats [event] on [date], or null when it doesn't hold it —
|
||||||
|
* the day isn't in this week, or the chip went into the day's "+N" overflow.
|
||||||
|
*/
|
||||||
|
fun seatOf(event: EventInstance, date: LocalDate): Offset? {
|
||||||
|
val col = days.indexOf(date).takeIf { it >= 0 } ?: return null
|
||||||
|
val lane = (0 until laneCount).firstOrNull { lane ->
|
||||||
|
chipAt(col, lane)?.let { isSameEvent(it, event) } == true
|
||||||
|
} ?: return null
|
||||||
|
return seat(col, lane)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [chip] is the moved event [moved] as the grid now holds it. Neither id
|
||||||
|
* alone will do: re-read instances get new instance ids, and a single-occurrence
|
||||||
|
* move writes a new event id — the title survives both.
|
||||||
|
*/
|
||||||
|
private fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean =
|
||||||
|
chip.eventId == moved.eventId || chip.title == moved.title
|
||||||
|
|
||||||
|
/** A chip in flight, in root coordinates so it can be drawn in an overlay. */
|
||||||
|
data class MonthChipDrag(
|
||||||
|
val event: EventInstance,
|
||||||
|
/** The day whose column the chip was grabbed in — the drag is a delta from it. */
|
||||||
|
val grabDate: LocalDate,
|
||||||
|
val targetDate: LocalDate?,
|
||||||
|
val topLeftInRoot: Offset,
|
||||||
|
val sizePx: IntSize,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Where a finished chip drag asks its event to go, as a whole-day shift. */
|
||||||
|
data class MonthChipDrop(
|
||||||
|
val event: EventInstance,
|
||||||
|
val grabDate: LocalDate,
|
||||||
|
val targetDate: LocalDate,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hoisted drag state for the month grid (#68). Every visible week row registers
|
||||||
|
* itself so a chip can be carried across rows, the target resolved against
|
||||||
|
* whichever registered row the finger is over. Rows are keyed by an identity
|
||||||
|
* token rather than a date: the continuous style can show the same week twice.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class MonthDragController {
|
||||||
|
private val rows = LinkedHashMap<Any, MonthRowGeometry>()
|
||||||
|
|
||||||
|
var drag: MonthChipDrag? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dropped chip, held on the day it landed on while the write runs — the
|
||||||
|
* grid behind it still shows the old day until the query re-reads.
|
||||||
|
*/
|
||||||
|
var settling: MonthChipDrag? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which chip is lifted, and whether anything is. Separate snapshot state from
|
||||||
|
* [drag], which changes every frame, so the chips and rows that only need
|
||||||
|
* "am I the ghost" don't recompose that often. Stays set through [settling].
|
||||||
|
*/
|
||||||
|
var liftedInstanceId: Long? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the grid itself now draws the drop, in root coordinates — published
|
||||||
|
* by whichever week row ends up holding it. The copy is dropped wherever the
|
||||||
|
* finger was while the grid seats it in a lane, so without this it would
|
||||||
|
* cross-fade across the gap instead of gliding onto its seat.
|
||||||
|
*/
|
||||||
|
var settledInRoot: Offset? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the drop's own chip is still standing in for the copy. Cleared at
|
||||||
|
* [handOver] rather than [release], or the chip stays dim under a copy that
|
||||||
|
* has already faded and brightens afterwards: a visible dip.
|
||||||
|
*/
|
||||||
|
private var settledGhost: Boolean by mutableStateOf(false)
|
||||||
|
|
||||||
|
/** Whether a finger is on a chip right now — [settling] is not dragging. */
|
||||||
|
var isDragging: Boolean by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last drop this controller made, kept past [release] for [beginUndo] —
|
||||||
|
* the confirmation chip carries Undo long after the copy is gone.
|
||||||
|
*/
|
||||||
|
private var undoable: MonthChipDrag? = null
|
||||||
|
|
||||||
|
private var event: EventInstance? = null
|
||||||
|
private var grabDate: LocalDate? = null
|
||||||
|
private var grab = Offset.Zero
|
||||||
|
private var pointer = Offset.Zero
|
||||||
|
private var sizePx = IntSize.Zero
|
||||||
|
|
||||||
|
fun putRow(token: Any, geometry: MonthRowGeometry) {
|
||||||
|
rows[token] = geometry
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeRow(token: Any) {
|
||||||
|
rows.remove(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun begin(
|
||||||
|
event: EventInstance,
|
||||||
|
grabDate: LocalDate,
|
||||||
|
pointerInRoot: Offset,
|
||||||
|
chipInRoot: Offset,
|
||||||
|
size: IntSize,
|
||||||
|
) {
|
||||||
|
this.event = event
|
||||||
|
this.grabDate = grabDate
|
||||||
|
settling = null
|
||||||
|
isDragging = true
|
||||||
|
liftedInstanceId = event.instanceId
|
||||||
|
grab = pointerInRoot - chipInRoot
|
||||||
|
pointer = pointerInRoot
|
||||||
|
sizePx = size
|
||||||
|
recompute()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun move(pointerInRoot: Offset) {
|
||||||
|
pointer = pointerInRoot
|
||||||
|
recompute()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
event = null
|
||||||
|
grabDate = null
|
||||||
|
isDragging = false
|
||||||
|
liftedInstanceId = null
|
||||||
|
drag = null
|
||||||
|
settling = null
|
||||||
|
settledInRoot = null
|
||||||
|
settledGhost = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [days] holds the chip the grid has drawn for the settled drop —
|
||||||
|
* the seat the copy is on its way to, which must ghost until it gets there.
|
||||||
|
*/
|
||||||
|
fun isSettledChip(event: EventInstance, days: List<LocalDate>?): Boolean {
|
||||||
|
val landed = settling?.takeIf { settledGhost } ?: return false
|
||||||
|
if (days == null || landed.targetDate !in days) return false
|
||||||
|
return isSameEvent(event, landed.event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the row keyed [token] whether it now seats the settled chip. Both
|
||||||
|
* copies of a week the continuous style shows twice will answer; the seat
|
||||||
|
* nearest where the chip was let go is the one the finger was over.
|
||||||
|
*/
|
||||||
|
fun noteSettled(token: Any) {
|
||||||
|
val landed = settling ?: return
|
||||||
|
val target = landed.targetDate ?: return
|
||||||
|
val at = rows[token]?.seatOf(landed.event, target) ?: return
|
||||||
|
val current = settledInRoot
|
||||||
|
val closer = current == null ||
|
||||||
|
abs(at.y - landed.topLeftInRoot.y) < abs(current.y - landed.topLeftInRoot.y)
|
||||||
|
if (closer) settledInRoot = at
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put a chip on the journey an undo's inverse write is about to make, so it
|
||||||
|
* travels back rather than teleporting. Writes nothing itself. False when
|
||||||
|
* there is no drop of this controller's left to undo, or the grid doesn't
|
||||||
|
* seat the moved event where the chip would have to start from.
|
||||||
|
*/
|
||||||
|
fun beginUndo(): Boolean {
|
||||||
|
val last = undoable ?: return false
|
||||||
|
undoable = null
|
||||||
|
val from = last.targetDate ?: return false
|
||||||
|
val at = rows.values.firstNotNullOfOrNull { it.seatOf(last.event, from) } ?: return false
|
||||||
|
settling = last.copy(grabDate = from, targetDate = last.grabDate, topLeftInRoot = at)
|
||||||
|
liftedInstanceId = last.event.instanceId
|
||||||
|
settledGhost = true
|
||||||
|
settledInRoot = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End the drag, handing back where it landed — null when it never resolved,
|
||||||
|
* or when it landed back on the day it came from. A real drop keeps its chip
|
||||||
|
* on the target day as [settling] until [release], slid over to that day's
|
||||||
|
* column so it lands where the grid is about to draw it.
|
||||||
|
*/
|
||||||
|
fun finish(): MonthChipDrop? {
|
||||||
|
val landed = drag
|
||||||
|
val left = landed?.targetDate?.let(::columnLeft)
|
||||||
|
cancel()
|
||||||
|
val target = landed?.targetDate?.takeIf { it != landed.grabDate } ?: return null
|
||||||
|
settling = landed.copy(
|
||||||
|
topLeftInRoot = Offset(left ?: landed.topLeftInRoot.x, landed.topLeftInRoot.y),
|
||||||
|
)
|
||||||
|
liftedInstanceId = landed.event.instanceId
|
||||||
|
settledGhost = true
|
||||||
|
undoable = settling
|
||||||
|
return MonthChipDrop(landed.event, landed.grabDate, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop ghosting the source while the copy is still dissolving — un-ghosting
|
||||||
|
* at [release] instead leaves the chip dim and then brightens it.
|
||||||
|
*/
|
||||||
|
fun handOver() {
|
||||||
|
liftedInstanceId = null
|
||||||
|
settledGhost = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the copy, once it has faded into the grid's own chip. */
|
||||||
|
fun release() {
|
||||||
|
settling = null
|
||||||
|
settledInRoot = null
|
||||||
|
handOver()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Root x of [date]'s column, in whichever visible row shows that day. */
|
||||||
|
private fun columnLeft(date: LocalDate): Float? = rows.values.firstNotNullOfOrNull { row ->
|
||||||
|
val index = row.days.indexOf(date).takeIf { it >= 0 } ?: return@firstNotNullOfOrNull null
|
||||||
|
val bounds = row.cell.takeIf { it.isAttached }?.boundsInRoot()
|
||||||
|
?: return@firstNotNullOfOrNull null
|
||||||
|
val column = if (row.isRtl) row.days.lastIndex - index else index
|
||||||
|
bounds.left + column * row.columnWidthPx
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recompute() {
|
||||||
|
val event = event ?: return
|
||||||
|
val grabbed = grabDate ?: return
|
||||||
|
val resolved = rows.values.firstNotNullOfOrNull { row ->
|
||||||
|
val bounds = row.cell.takeIf { it.isAttached }?.boundsInRoot()
|
||||||
|
?: return@firstNotNullOfOrNull null
|
||||||
|
if (!bounds.contains(pointer) || row.columnWidthPx <= 0f) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
val column = ((pointer.x - bounds.left) / row.columnWidthPx)
|
||||||
|
.toInt()
|
||||||
|
.coerceIn(0, row.days.lastIndex)
|
||||||
|
row.days[if (row.isRtl) row.days.lastIndex - column else column]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drag = MonthChipDrag(
|
||||||
|
event = event,
|
||||||
|
grabDate = grabbed,
|
||||||
|
// Off the grid entirely: keep the last cell it was over, so a
|
||||||
|
// wobble past the edge doesn't drop the target.
|
||||||
|
targetDate = resolved ?: drag?.targetDate,
|
||||||
|
topLeftInRoot = pointer - grab,
|
||||||
|
sizePx = sizePx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberMonthDragController(): MonthDragController = remember { MonthDragController() }
|
||||||
|
|
||||||
|
val LocalMonthDrag = compositionLocalOf<MonthDragController?> { null }
|
||||||
@@ -4,7 +4,9 @@ import androidx.activity.compose.BackHandler
|
|||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
import androidx.compose.animation.SharedTransitionLayout
|
import androidx.compose.animation.SharedTransitionLayout
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.VectorConverter
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.snap
|
import androidx.compose.animation.core.snap
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
@@ -59,9 +61,11 @@ import androidx.compose.material3.TopAppBarDefaults
|
|||||||
import androidx.compose.material3.rememberDrawerState
|
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.SideEffect
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
@@ -77,7 +81,32 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.foundation.layout.absoluteOffset
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.moveInFlight
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.semantics.customActions
|
||||||
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
@@ -101,6 +130,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
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.CalendarTitleButton
|
||||||
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.TodayAction
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
@@ -110,6 +140,7 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
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.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
@@ -124,6 +155,7 @@ import de.jeanlucmakiola.floret.locale.currentLocale
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -329,6 +361,7 @@ fun MonthScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
currentDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
||||||
|
drawerState = drawerState,
|
||||||
viewOrder = drawerViewOrder,
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
@@ -350,10 +383,12 @@ fun MonthScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
MonthTopBar(
|
MonthTopBar(
|
||||||
title = topBarTitle,
|
title = topBarTitle,
|
||||||
|
titleDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
||||||
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,
|
||||||
|
onJumpToDate = jumpToDate,
|
||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
@@ -379,13 +414,23 @@ fun MonthScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(
|
// Hoisted above every style's grid: a dragged chip is drawn in an
|
||||||
|
// overlay, clear of the week row's and the list viewport's clips.
|
||||||
|
val chipDrag = rememberMonthDragController()
|
||||||
|
// Undo moves the event back, so the chip travels back too. The
|
||||||
|
// signal comes from the write, not from the chip that offers it.
|
||||||
|
OnUndoStarted { chipDrag.beginUndo() }
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
|
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
|
||||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
CompositionLocalProvider(
|
||||||
|
LocalDimCutoff provides dimCutoff,
|
||||||
|
LocalMonthDrag provides chipDrag,
|
||||||
|
) {
|
||||||
if (scrolling) {
|
if (scrolling) {
|
||||||
ContinuousMonthContent(
|
ContinuousMonthContent(
|
||||||
state = continuousState,
|
state = continuousState,
|
||||||
@@ -422,8 +467,101 @@ fun MonthScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
MonthDragOverlay(chipDrag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long to wait for a grid that never seats the drop — it landed in a day's
|
||||||
|
* "+N" overflow or on a day this month doesn't show, or the write failed.
|
||||||
|
*/
|
||||||
|
private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L
|
||||||
|
|
||||||
|
/** The chip in flight, drawn over the grid and following the finger. */
|
||||||
|
@Composable
|
||||||
|
private fun MonthDragOverlay(controller: MonthDragController) {
|
||||||
|
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
val moveInFlight = moveInFlight()
|
||||||
|
// Here rather than beside the controller: this reads [drag], which changes
|
||||||
|
// every frame, and this composable is the one that is meant to.
|
||||||
|
DragSnapHaptics(controller.drag?.targetDate)
|
||||||
|
// Hold the landed chip until the write is done — including the time a
|
||||||
|
// recurring drop's scope dialog is up — and then, unlike the timeline, carry
|
||||||
|
// it to its seat: it was dropped at whatever height the finger was at, while
|
||||||
|
// the grid seats it in a lane.
|
||||||
|
var handingOver by remember(controller.settling) { mutableStateOf(false) }
|
||||||
|
var gliding by remember(controller.settling) { mutableStateOf(false) }
|
||||||
|
val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||||
|
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
|
||||||
|
val seat = controller.settledInRoot
|
||||||
|
LaunchedEffect(controller.settling, moveInFlight, seat) {
|
||||||
|
val landed = controller.settling
|
||||||
|
if (landed == null || moveInFlight) return@LaunchedEffect
|
||||||
|
if (seat == null) {
|
||||||
|
delay(MONTH_SETTLE_TIMEOUT_MILLIS)
|
||||||
|
} else {
|
||||||
|
if (!gliding) glide.snapTo(landed.topLeftInRoot)
|
||||||
|
gliding = true
|
||||||
|
if (reduceMotion) glide.snapTo(seat) else glide.animateTo(seat, glideSpec)
|
||||||
|
delay(SETTLE_GRACE_MILLIS)
|
||||||
|
}
|
||||||
|
handingOver = true
|
||||||
|
controller.handOver()
|
||||||
|
delay(SETTLE_FADE_MILLIS.toLong())
|
||||||
|
controller.release()
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
// A copy of a chip still in the tree behind it; announcing it again
|
||||||
|
// would duplicate the event for the drag's duration.
|
||||||
|
.clearAndSetSemantics { }
|
||||||
|
.onGloballyPositioned { origin = it.positionInRoot() },
|
||||||
|
) {
|
||||||
|
val drag = controller.drag ?: controller.settling ?: return@Box
|
||||||
|
// Landed: the copy sinks back to the grid's own plane while the write runs.
|
||||||
|
val lift by animateFloatAsState(
|
||||||
|
targetValue = if (controller.drag == null || reduceMotion) 0f else 1f,
|
||||||
|
label = "chip-lift",
|
||||||
|
)
|
||||||
|
val copyAlpha by animateFloatAsState(
|
||||||
|
targetValue = if (handingOver) 0f else 1f,
|
||||||
|
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||||
|
label = "chip-handover",
|
||||||
|
)
|
||||||
|
MonthBar(
|
||||||
|
event = drag.event,
|
||||||
|
dark = dark,
|
||||||
|
continuesLeft = false,
|
||||||
|
continuesRight = false,
|
||||||
|
modifier = Modifier
|
||||||
|
// Absolute: these are root coordinates, and the direction-aware
|
||||||
|
// offset would mirror them across the screen in an RTL layout.
|
||||||
|
.absoluteOffset {
|
||||||
|
val at = if (gliding) glide.value else drag.topLeftInRoot
|
||||||
|
IntOffset(
|
||||||
|
(at.x - origin.x).roundToInt(),
|
||||||
|
(at.y - origin.y).roundToInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.width(with(density) { drag.sizePx.width.toDp() })
|
||||||
|
.height(with(density) { drag.sizePx.height.toDp() })
|
||||||
|
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = 1f + 0.04f * lift
|
||||||
|
scaleY = 1f + 0.04f * lift
|
||||||
|
shadowElevation = 8.dp.toPx() * lift
|
||||||
|
alpha = copyAlpha
|
||||||
|
shape = RoundedCornerShape(4.dp)
|
||||||
|
clip = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -509,19 +647,22 @@ private fun ContinuousMonthContent(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MonthTopBar(
|
private fun MonthTopBar(
|
||||||
title: String,
|
title: String,
|
||||||
|
titleDate: LocalDate,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
showTodayButton: Boolean,
|
showTodayButton: Boolean,
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
CalendarTitleButton(
|
||||||
text = title,
|
title = title,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
currentDate = titleDate,
|
||||||
|
onJumpToDate = onJumpToDate,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -1473,7 +1614,7 @@ private fun SplitDots(
|
|||||||
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
|
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
|
||||||
.size(SPLIT_DOT_SIZE)
|
.size(SPLIT_DOT_SIZE)
|
||||||
.alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f)
|
.alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f)
|
||||||
.background(eventFill(event.color, dark, soften), CircleShape),
|
.background(eventAccent(event.color, dark, soften), CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (hidden.isNotEmpty()) {
|
if (hidden.isNotEmpty()) {
|
||||||
@@ -1712,6 +1853,50 @@ private fun MonthWeekRow(
|
|||||||
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
||||||
val morphing = morphInFlight()
|
val morphing = morphInFlight()
|
||||||
|
|
||||||
|
// Drag to reschedule (#68). The chips can take no pointer input of their own
|
||||||
|
// — the full-bleed tap layer sits on top of them — so one detector on the
|
||||||
|
// row's day-column box hit-tests them geometrically instead.
|
||||||
|
val moveScope = LocalEventMove.current
|
||||||
|
val dragController = LocalMonthDrag.current
|
||||||
|
val rowToken = remember { Any() }
|
||||||
|
val bandCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
|
||||||
|
val cellCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val rowHeightPx = with(density) { EVENT_ROW_HEIGHT.toPx() }
|
||||||
|
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||||
|
val dragging = dragController?.isDragging == true
|
||||||
|
DisposableEffect(rowToken, dragController) {
|
||||||
|
onDispose { dragController?.removeRow(rowToken) }
|
||||||
|
}
|
||||||
|
// Republished on layout *and* on every recomposition: the coordinates change
|
||||||
|
// only on the former, but what the row draws — which the controller reads to
|
||||||
|
// find a moved chip's seat — changes on the latter.
|
||||||
|
val publish = {
|
||||||
|
val cell = cellCoordinates[0]?.takeIf { it.isAttached }
|
||||||
|
if (dragController != null && cell != null) {
|
||||||
|
dragController.putRow(
|
||||||
|
rowToken,
|
||||||
|
MonthRowGeometry(
|
||||||
|
days = week.days,
|
||||||
|
cell = cell,
|
||||||
|
band = bandCoordinates[0],
|
||||||
|
columnWidthPx = cell.size.width / 7f,
|
||||||
|
laneHeightPx = rowHeightPx,
|
||||||
|
laneCount = MAX_EVENT_ROWS,
|
||||||
|
isRtl = isRtl,
|
||||||
|
chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SideEffect { publish() }
|
||||||
|
// Once the grid holds the settled chip, tell the controller where this row
|
||||||
|
// has seated it, so the copy in flight can glide onto it. Keyed on the week,
|
||||||
|
// which is what changes when the re-read lands.
|
||||||
|
LaunchedEffect(week, dragController?.settling) {
|
||||||
|
dragController?.noteSettled(rowToken)
|
||||||
|
}
|
||||||
|
|
||||||
Row(modifier) {
|
Row(modifier) {
|
||||||
// Optional calendar-week gutter, sized so the seven day columns below
|
// Optional calendar-week gutter, sized so the seven day columns below
|
||||||
// divide the remaining width — the absolute bar offsets stay correct
|
// divide the remaining width — the absolute bar offsets stay correct
|
||||||
@@ -1727,7 +1912,21 @@ private fun MonthWeekRow(
|
|||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight()
|
||||||
|
.onGloballyPositioned { coords ->
|
||||||
|
cellCoordinates[0] = coords
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
.then(
|
||||||
|
monthChipDragModifier(
|
||||||
|
week = week,
|
||||||
|
moveScope = moveScope,
|
||||||
|
controller = dragController,
|
||||||
|
band = bandCoordinates,
|
||||||
|
rowHeightPx = rowHeightPx,
|
||||||
|
isRtl = isRtl,
|
||||||
|
),
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
val colW = maxWidth / 7
|
val colW = maxWidth / 7
|
||||||
|
|
||||||
@@ -1792,7 +1991,13 @@ private fun MonthWeekRow(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.then(if (morphing) Modifier else Modifier.clipToBounds()),
|
.onGloballyPositioned {
|
||||||
|
bandCoordinates[0] = it
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
// A dragged chip travels to another row, so the clip
|
||||||
|
// yields for it as it does for a morph.
|
||||||
|
.then(if (morphing || dragging) Modifier else Modifier.clipToBounds()),
|
||||||
) {
|
) {
|
||||||
// Spanning bars on their shared lanes.
|
// Spanning bars on their shared lanes.
|
||||||
week.spans.filter { it.lane < shownLanes }.forEach { span ->
|
week.spans.filter { it.lane < shownLanes }.forEach { span ->
|
||||||
@@ -1802,6 +2007,7 @@ private fun MonthWeekRow(
|
|||||||
dark = dark,
|
dark = dark,
|
||||||
continuesLeft = span.continuesLeft,
|
continuesLeft = span.continuesLeft,
|
||||||
continuesRight = span.continuesRight,
|
continuesRight = span.continuesRight,
|
||||||
|
days = week.days.subList(span.startCol, span.endCol + 1),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(
|
.offset(
|
||||||
x = colW * span.startCol,
|
x = colW * span.startCol,
|
||||||
@@ -1871,6 +2077,7 @@ private fun MonthWeekRow(
|
|||||||
dark = dark,
|
dark = dark,
|
||||||
continuesLeft = false,
|
continuesLeft = false,
|
||||||
continuesRight = false,
|
continuesRight = false,
|
||||||
|
days = listOf(d),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(
|
.offset(
|
||||||
x = colW * col,
|
x = colW * col,
|
||||||
@@ -1951,6 +2158,73 @@ private fun MonthWeekRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The row-level pickup for month chips: resolves which chip the press landed on
|
||||||
|
* from the geometry the row just laid out, and abandons the gesture on empty
|
||||||
|
* space (or an unmovable calendar) so tapping a day still opens it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun monthChipDragModifier(
|
||||||
|
week: MonthWeek,
|
||||||
|
moveScope: EventMoveScope?,
|
||||||
|
controller: MonthDragController?,
|
||||||
|
band: Array<LayoutCoordinates?>,
|
||||||
|
rowHeightPx: Float,
|
||||||
|
isRtl: Boolean,
|
||||||
|
): Modifier = rememberDragSurface(
|
||||||
|
enabled = moveScope?.dragEnabled == true && controller != null,
|
||||||
|
key = week.days.first(),
|
||||||
|
onPickUp = { local, pointerInRoot, nodeInRoot, size ->
|
||||||
|
val bandTop = band[0]?.takeIf { it.isAttached }?.positionInRoot()?.y
|
||||||
|
val columnPx = size.width / 7f
|
||||||
|
val bandY = if (bandTop == null) -1f else local.y - (bandTop - nodeInRoot.y)
|
||||||
|
val lane = (bandY / rowHeightPx).toInt()
|
||||||
|
// The column under the finger on screen, and the day that column shows.
|
||||||
|
val column = (local.x / columnPx).toInt()
|
||||||
|
val dayIndex = if (isRtl) week.days.lastIndex - column else column
|
||||||
|
val event = if (bandY < 0f || columnPx <= 0f) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
week.chipAt(dayIndex, lane, MAX_EVENT_ROWS)
|
||||||
|
}
|
||||||
|
if (event == null || moveScope?.allows(event) != true) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
controller?.begin(
|
||||||
|
event = event,
|
||||||
|
grabDate = week.days[dayIndex],
|
||||||
|
pointerInRoot = pointerInRoot,
|
||||||
|
chipInRoot = Offset(
|
||||||
|
x = nodeInRoot.x + column * columnPx,
|
||||||
|
y = requireNotNull(bandTop) + lane * rowHeightPx,
|
||||||
|
),
|
||||||
|
size = IntSize(columnPx.toInt(), rowHeightPx.toInt()),
|
||||||
|
)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMove = { controller?.move(it) },
|
||||||
|
onDrop = {
|
||||||
|
controller?.finish()?.let { drop ->
|
||||||
|
// How many columns the finger crossed — grabbing the middle of a
|
||||||
|
// multi-day bar shifts by what it travelled, not to where it landed.
|
||||||
|
val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt()
|
||||||
|
val took = moveScope?.move(
|
||||||
|
MoveRequest(
|
||||||
|
eventId = drop.event.eventId,
|
||||||
|
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||||
|
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||||
|
target = MoveTarget.ByDays(delta),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Refused, so nothing will land: let the copy go now rather than
|
||||||
|
// hold the chip out for a settle that can never arrive.
|
||||||
|
if (took != true) controller?.release()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCancel = { controller?.cancel() },
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the
|
* Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the
|
||||||
* day cells' geometry, set apart by the secondaryContainer tint (matching the
|
* day cells' geometry, set apart by the secondaryContainer tint (matching the
|
||||||
@@ -2043,12 +2317,24 @@ private fun MonthBar(
|
|||||||
continuesLeft: Boolean,
|
continuesLeft: Boolean,
|
||||||
continuesRight: Boolean,
|
continuesRight: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
/**
|
||||||
|
* The days this chip covers in its row, for the drag (#68). A drop's own chip
|
||||||
|
* has to ghost until the copy lands on it, and the instance id can't find it:
|
||||||
|
* the provider hands the re-read instance a new one.
|
||||||
|
*/
|
||||||
|
days: List<LocalDate>? = null,
|
||||||
) {
|
) {
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
val fill = eventFill(event.color, dark, soften)
|
val fill = eventFill(event.color, dark, soften)
|
||||||
|
val moveAction = eventMoveAction(event)
|
||||||
|
// The source stays put as a ghost while its floating copy travels.
|
||||||
|
val monthDrag = LocalMonthDrag.current
|
||||||
|
val lifted = monthDrag?.liftedInstanceId == event.instanceId ||
|
||||||
|
monthDrag?.isSettledChip(event, days) == true
|
||||||
|
val ghost = ghostAlpha(lifted)
|
||||||
val shape = RoundedCornerShape(
|
val shape = RoundedCornerShape(
|
||||||
topStart = if (continuesLeft) 0.dp else 4.dp,
|
topStart = if (continuesLeft) 0.dp else 4.dp,
|
||||||
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
||||||
@@ -2057,9 +2343,13 @@ private fun MonthBar(
|
|||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
|
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||||
.background(fill, shape)
|
.background(fill, shape)
|
||||||
.padding(horizontal = 4.dp)
|
.padding(horizontal = 4.dp)
|
||||||
.semantics { contentDescription = title },
|
.semantics {
|
||||||
|
contentDescription = title
|
||||||
|
if (moveAction != null) customActions = listOf(moveAction)
|
||||||
|
},
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -2098,7 +2388,7 @@ private fun OverflowDots(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(6.dp)
|
.size(6.dp)
|
||||||
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
|
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
|
||||||
.background(eventFill(argb, dark, soften), CircleShape),
|
.background(eventAccent(argb, dark, soften), CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val extra = total - dots.size
|
val extra = total - dots.size
|
||||||
|
|||||||
@@ -1,54 +1,31 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.month
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
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 androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.sampleMonthEvents
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.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 kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
import kotlinx.datetime.atTime
|
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings
|
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings
|
||||||
* chooser.
|
* and onboarding choosers.
|
||||||
*
|
|
||||||
* 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
|
* 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
|
* are stand-ins. See [ScaledViewPreview] for how the real grid composables get
|
||||||
* provider for a thumbnail.
|
* measured at phone size and shrunk into the card.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MonthStylePreview(
|
internal fun MonthStylePreview(
|
||||||
@@ -57,50 +34,11 @@ internal fun MonthStylePreview(
|
|||||||
height: Dp,
|
height: Dp,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
|
||||||
val scale = height.value / VIRTUAL_HEIGHT.value
|
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||||
val sample = remember(today, weekStart, zone) { sampleMonthState(today, weekStart, zone) }
|
val sample = remember(today, weekStart, zone) { sampleMonthState(today, weekStart, zone) }
|
||||||
|
|
||||||
Box(
|
ScaledViewPreview(height = height, modifier = modifier) {
|
||||||
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)
|
WeekdayHeader(weekStart = weekStart, showWeekNumbers = false)
|
||||||
when (style) {
|
when (style) {
|
||||||
MonthViewStyle.Paged -> MonthGrid(
|
MonthViewStyle.Paged -> MonthGrid(
|
||||||
@@ -121,11 +59,10 @@ internal fun MonthStylePreview(
|
|||||||
MonthViewStyle.Dense -> DenseMonthGrid(
|
MonthViewStyle.Dense -> DenseMonthGrid(
|
||||||
state = sample.continuous,
|
state = sample.continuous,
|
||||||
listState = rememberLazyListState(
|
listState = rememberLazyListState(
|
||||||
// Start a week above today's, so the preview shows a
|
// Start a week above today's, so the preview shows a stream
|
||||||
// stream running past the viewport rather than one
|
// running past the viewport rather than one beginning at its
|
||||||
// beginning at its top edge.
|
// top edge.
|
||||||
initialFirstVisibleItemIndex =
|
initialFirstVisibleItemIndex = weekIndexOf(today, weekStart) - 1,
|
||||||
weekIndexOf(today, weekStart) - 1,
|
|
||||||
),
|
),
|
||||||
showWeekNumbers = false,
|
showWeekNumbers = false,
|
||||||
onOpenDay = {},
|
onOpenDay = {},
|
||||||
@@ -150,15 +87,8 @@ internal fun MonthStylePreview(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 val SPLIT_PANE_HEIGHT = 170.dp
|
||||||
|
|
||||||
private class SampleMonth(
|
private class SampleMonth(
|
||||||
@@ -170,9 +100,6 @@ private class SampleMonth(
|
|||||||
* A month's worth of stand-in events, laid out through the same
|
* A month's worth of stand-in events, laid out through the same
|
||||||
* [layoutMonthWeeks] / [clipWeekToMonth] the live views use — so the preview
|
* [layoutMonthWeeks] / [clipWeekToMonth] the live views use — so the preview
|
||||||
* exercises the real span, lane and overflow logic rather than approximating it.
|
* 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(
|
private fun sampleMonthState(
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
@@ -181,7 +108,7 @@ private fun sampleMonthState(
|
|||||||
): SampleMonth {
|
): SampleMonth {
|
||||||
val ym = YearMonth(today.year, today.month)
|
val ym = YearMonth(today.year, today.month)
|
||||||
val first = LocalDate(ym.year, ym.month, 1)
|
val first = LocalDate(ym.year, ym.month, 1)
|
||||||
val events = sampleEvents(first, today, zone)
|
val events = sampleMonthEvents(first, today, zone)
|
||||||
|
|
||||||
val weeks = layoutMonthWeeks(ym, weekStart, events, zone)
|
val weeks = layoutMonthWeeks(ym, weekStart, events, zone)
|
||||||
val month = MonthUiState.Success(
|
val month = MonthUiState.Success(
|
||||||
@@ -213,72 +140,3 @@ private fun sampleMonthState(
|
|||||||
)
|
)
|
||||||
return SampleMonth(month, continuous)
|
return SampleMonth(month, continuous)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val SAMPLE_COLORS = listOf(
|
|
||||||
0xFF3F7BD4.toInt(),
|
|
||||||
0xFFCE5B4C.toInt(),
|
|
||||||
0xFF4E9A6A.toInt(),
|
|
||||||
0xFF8A63C7.toInt(),
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun sampleEvents(
|
|
||||||
firstOfMonth: LocalDate,
|
|
||||||
today: LocalDate,
|
|
||||||
zone: TimeZone,
|
|
||||||
): List<EventInstance> {
|
|
||||||
var id = 0L
|
|
||||||
fun next() = ++id
|
|
||||||
|
|
||||||
fun timed(day: LocalDate, hour: Int, length: Int, colorIndex: Int) = EventInstance(
|
|
||||||
instanceId = next(),
|
|
||||||
eventId = id,
|
|
||||||
calendarId = 1L,
|
|
||||||
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
|
|
||||||
start = day.atTime(hour, 0).toInstant(zone),
|
|
||||||
end = day.atTime(hour + length, 0).toInstant(zone),
|
|
||||||
isAllDay = false,
|
|
||||||
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
|
||||||
location = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun allDay(from: LocalDate, days: Int, colorIndex: Int) = EventInstance(
|
|
||||||
instanceId = next(),
|
|
||||||
eventId = id,
|
|
||||||
calendarId = 1L,
|
|
||||||
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
|
|
||||||
// All-day events sit at UTC midnights with an exclusive end.
|
|
||||||
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
|
||||||
end = from.plus(days, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
|
||||||
isAllDay = true,
|
|
||||||
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
|
||||||
location = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Spread across the month so most weeks carry something, with one multi-day
|
|
||||||
// bar to show a span bridging cells and a busy today for the split pane.
|
|
||||||
return buildList {
|
|
||||||
add(allDay(firstOfMonth.plus(9, DateTimeUnit.DAY), days = 3, colorIndex = 2))
|
|
||||||
add(timed(firstOfMonth.plus(1, DateTimeUnit.DAY), 9, 1, 0))
|
|
||||||
add(timed(firstOfMonth.plus(4, DateTimeUnit.DAY), 14, 2, 1))
|
|
||||||
add(timed(firstOfMonth.plus(7, DateTimeUnit.DAY), 11, 1, 3))
|
|
||||||
add(timed(firstOfMonth.plus(15, DateTimeUnit.DAY), 10, 1, 0))
|
|
||||||
add(timed(firstOfMonth.plus(18, DateTimeUnit.DAY), 16, 1, 2))
|
|
||||||
add(timed(firstOfMonth.plus(22, DateTimeUnit.DAY), 8, 2, 1))
|
|
||||||
add(timed(firstOfMonth.plus(25, DateTimeUnit.DAY), 13, 1, 3))
|
|
||||||
// Today, so the split pane's list and the grid's dots both have content.
|
|
||||||
add(timed(today, 9, 1, 0))
|
|
||||||
add(timed(today, 12, 1, 1))
|
|
||||||
add(timed(today, 15, 2, 3))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val SAMPLE_TITLES = listOf(
|
|
||||||
"Standup",
|
|
||||||
"Lunch",
|
|
||||||
"Review",
|
|
||||||
"Gym",
|
|
||||||
"Call",
|
|
||||||
"Workshop",
|
|
||||||
"Dentist",
|
|
||||||
"Trip",
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -67,6 +67,23 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
|
|||||||
return byLane.filterNotNull()
|
return byLane.filterNotNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event drawn in lane [lane] of column [col], or null for an empty slot.
|
||||||
|
* Unlike [laneEvents] the lanes are *not* compacted — this answers "what is under
|
||||||
|
* this point", so an empty lane above a seated bar has to stay empty (#68).
|
||||||
|
*/
|
||||||
|
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
|
||||||
|
if (col !in days.indices || lane !in 0 until laneCap) return null
|
||||||
|
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.let { return it.event }
|
||||||
|
val occupied = spans
|
||||||
|
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
|
||||||
|
.map { it.lane }
|
||||||
|
.toSet()
|
||||||
|
val free = (0 until laneCap).filter { it !in occupied }
|
||||||
|
val index = free.indexOf(lane).takeIf { it >= 0 } ?: return null
|
||||||
|
return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The events on [day] that [laneEvents] had no lane left for — its exact
|
* The events on [day] that [laneEvents] had no lane left for — its exact
|
||||||
* complement, in the same bars-then-pills order. Returned as events rather than
|
* complement, in the same bars-then-pills order. Returned as events rather than
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CloudOff
|
||||||
|
import androidx.compose.material.icons.filled.FolderOpen
|
||||||
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wizard step shown only when everything you write to is a device-only calendar
|
||||||
|
* (#163): the events exist nowhere but this phone, so losing it loses them.
|
||||||
|
*
|
||||||
|
* The primary action picks a folder and switches automatic backup on in one go.
|
||||||
|
* Everything it sets is a normal Backup setting, adjustable afterwards.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun BackupStep(
|
||||||
|
onEnable: (Uri) -> Unit,
|
||||||
|
onSkip: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
progress: (@Composable () -> Unit)? = null,
|
||||||
|
navigationIcon: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val pickFolder = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||||
|
) { uri ->
|
||||||
|
// Cancelling the picker decides nothing and leaves the step open.
|
||||||
|
uri?.let(onEnable)
|
||||||
|
}
|
||||||
|
|
||||||
|
OnboardingScaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
topSpacing = OnboardingSpace.lg,
|
||||||
|
hero = { IconHero(Icons.Filled.CloudOff) },
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = { runCatching { pickFolder.launch(null) } },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_backup_enable_button),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = onSkip,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.onboarding_backup_skip_button))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_backup_title),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_backup_body),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||||
|
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.FolderOpen,
|
||||||
|
title = stringResource(R.string.onboarding_backup_benefit_folder_title),
|
||||||
|
body = stringResource(R.string.onboarding_backup_benefit_folder_body),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.Schedule,
|
||||||
|
title = stringResource(R.string.onboarding_backup_benefit_daily_title),
|
||||||
|
body = stringResource(R.string.onboarding_backup_benefit_daily_body),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.permission
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
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.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -16,20 +17,25 @@ import androidx.compose.runtime.Composable
|
|||||||
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.vector.ImageVector
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/** What, if anything, the brand mark is wearing in its corner. */
|
||||||
|
internal enum class HeroBadge { Denied, Done }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The app's adaptive launcher mark, reconstructed as a large branded squircle —
|
* The app's adaptive launcher mark, reconstructed as a large branded squircle —
|
||||||
* the hero of the onboarding screens (floret-kit's OnboardingScaffold supplies
|
* the hero of the onboarding screens (floret-kit's OnboardingScaffold supplies
|
||||||
* the shell; the mark stays app-local so each sibling keeps its own identity).
|
* the shell; the mark stays app-local so each sibling keeps its own identity).
|
||||||
* A lock badge overlays the corner when permission has been [denied].
|
* A [badge] overlays the corner where the screen has a verdict to give: blocked,
|
||||||
|
* or finished.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun BrandHero(denied: Boolean) {
|
internal fun BrandHero(badge: HeroBadge? = null) {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -43,24 +49,56 @@ internal fun BrandHero(denied: Boolean) {
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (denied) {
|
if (badge != null) {
|
||||||
// A small lock badge sits over the corner to signal "blocked".
|
val container = when (badge) {
|
||||||
|
HeroBadge.Denied -> MaterialTheme.colorScheme.errorContainer
|
||||||
|
HeroBadge.Done -> MaterialTheme.colorScheme.primaryContainer
|
||||||
|
}
|
||||||
|
val onContainer = when (badge) {
|
||||||
|
HeroBadge.Denied -> MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
HeroBadge.Done -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.offset(x = 10.dp, y = 10.dp)
|
.offset(x = 10.dp, y = 10.dp)
|
||||||
.size(44.dp)
|
.size(44.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(MaterialTheme.colorScheme.errorContainer),
|
.background(container),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Lock,
|
imageVector = when (badge) {
|
||||||
|
HeroBadge.Denied -> Icons.Filled.Lock
|
||||||
|
HeroBadge.Done -> Icons.Filled.Check
|
||||||
|
},
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
tint = onContainer,
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single [icon] in the brand squircle — the same silhouette as [BrandHero],
|
||||||
|
* for the wizard steps that stand for a feature rather than for the app itself.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun IconHero(icon: ImageVector) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(128.dp)
|
||||||
|
.clip(RoundedCornerShape(34.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wizard's closing screen (#163): the flow ends on a full screen saying so
|
||||||
|
* rather than dropping into the calendar mid-gesture, and the brand mark wears
|
||||||
|
* the check that the last step earned.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun DoneStep(
|
||||||
|
onFinished: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
progress: (@Composable () -> Unit)? = null,
|
||||||
|
navigationIcon: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
OnboardingScaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
topSpacing = OnboardingSpace.lg,
|
||||||
|
hero = { BrandHero(HeroBadge.Done) },
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = onFinished,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_done_button),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_done_title),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_done_body),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
|
import androidx.compose.animation.ContentTransform
|
||||||
|
import androidx.compose.animation.SizeTransform
|
||||||
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The M3 Expressive spatial spring the wizard's steps travel on. Read in
|
||||||
|
* composable scope so it can be captured by `AnimatedContent`'s non-composable
|
||||||
|
* transitionSpec lambda.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun rememberOnboardingSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
||||||
|
MaterialTheme.motionScheme.defaultSpatialSpec()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moving between wizard steps (#163): the steps sit side by side and the whole
|
||||||
|
* width swipes across, position on a spring so the arriving step settles with a
|
||||||
|
* little give rather than stopping dead.
|
||||||
|
*
|
||||||
|
* No cross-fade, unlike the calendar's shared-axis paging: two steps have
|
||||||
|
* nothing in common, so fading one through the other smears. Both are opaque and
|
||||||
|
* travel in lockstep, showing one screen pushing the other off.
|
||||||
|
*
|
||||||
|
* @param slideDir +1 = forward (incoming from the right), -1 = back.
|
||||||
|
* @param spec spatial spec, typically [rememberOnboardingSlideSpec].
|
||||||
|
* @param fadeSpec effects spec used for the whole transition under reduced
|
||||||
|
* motion.
|
||||||
|
* @param reduceMotion when true, drop the movement and cross-fade alone.
|
||||||
|
*/
|
||||||
|
internal fun onboardingSlideTransition(
|
||||||
|
slideDir: Int,
|
||||||
|
spec: FiniteAnimationSpec<IntOffset>,
|
||||||
|
fadeSpec: FiniteAnimationSpec<Float>,
|
||||||
|
reduceMotion: Boolean,
|
||||||
|
): ContentTransform {
|
||||||
|
if (reduceMotion) {
|
||||||
|
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
||||||
|
}
|
||||||
|
val dir = if (slideDir == 0) 1 else slideDir
|
||||||
|
return ContentTransform(
|
||||||
|
targetContentEnter = slideInHorizontally(spec) { w -> dir * w },
|
||||||
|
initialContentExit = slideOutHorizontally(spec) { w -> -dir * w },
|
||||||
|
// Clipping to the animating container would shear both steps against the
|
||||||
|
// viewport edge as they pass; there is no size change here to contain.
|
||||||
|
sizeTransform = SizeTransform(clip = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
|
/** One screen of the first-launch wizard (#163), in the order they are shown. */
|
||||||
|
enum class OnboardingStep {
|
||||||
|
/** Required: nothing works without the calendar grant. */
|
||||||
|
Permission,
|
||||||
|
|
||||||
|
/** Whether Calendula delivers reminder notifications itself. */
|
||||||
|
Reminders,
|
||||||
|
|
||||||
|
/** Only when nothing you write to is synced anywhere — offers a backup. */
|
||||||
|
Backup,
|
||||||
|
|
||||||
|
/** Which view the app opens on, chosen from a live preview. */
|
||||||
|
View,
|
||||||
|
|
||||||
|
/** How the Month view lays itself out, chosen from a live preview. */
|
||||||
|
MonthStyle,
|
||||||
|
|
||||||
|
/** Only on an install that has calendars the device is hiding (#75). */
|
||||||
|
Visibility,
|
||||||
|
|
||||||
|
/** The closing screen: everything is answered and the app is ready. */
|
||||||
|
Done,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which steps this install owes and where it has got to. [steps] is the whole
|
||||||
|
* flow — completed steps included — so the counter doesn't renumber as steps
|
||||||
|
* are finished; [current] is null once nothing is left and the app opens.
|
||||||
|
*/
|
||||||
|
data class OnboardingPlan(
|
||||||
|
val steps: List<OnboardingStep>,
|
||||||
|
val current: OnboardingStep?,
|
||||||
|
) {
|
||||||
|
/** 1-based position of [current] in [steps], or 0 when the flow is done. */
|
||||||
|
val index: Int get() = steps.indexOf(current) + 1
|
||||||
|
|
||||||
|
val total: Int get() = steps.size
|
||||||
|
|
||||||
|
/** A one-step flow is not a wizard: there is nothing to count down. */
|
||||||
|
val showsProgress: Boolean get() = total > 1
|
||||||
|
|
||||||
|
/** The step before [current], or null at the start (and once finished). */
|
||||||
|
val previous: OnboardingStep? get() = steps.getOrNull(index - 2).takeIf { current != null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the wizard can step back. Everything is re-askable except the
|
||||||
|
* calendar grant, which belongs to the system — once given there is nothing
|
||||||
|
* for a back press to return to.
|
||||||
|
*/
|
||||||
|
val canGoBack: Boolean get() = previous != null && previous != OnboardingStep.Permission
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Work out the flow from what is stored.
|
||||||
|
*
|
||||||
|
* The optional steps belong to installs still working through their first run.
|
||||||
|
* Before the grant that can only be guessed, so one that has not answered the
|
||||||
|
* reminder step either counts as fresh — the same condition
|
||||||
|
* [de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs.armOnboardingWizard]
|
||||||
|
* commits to storage a moment later, and clears again on the closing screen so
|
||||||
|
* a later re-grant is only the permission step.
|
||||||
|
*
|
||||||
|
* [backupApplies] is null until the calendar list can be read, i.e. for the
|
||||||
|
* whole permission step; the backup step is assumed to apply until proven
|
||||||
|
* otherwise, so the flow can only ever get shorter — never sprout a step the
|
||||||
|
* counter had not accounted for.
|
||||||
|
*
|
||||||
|
* The month-style step is *not* conditional on Month being the chosen view:
|
||||||
|
* Month is reachable from the drawer whatever opens first, and a step that came
|
||||||
|
* and went as the view is picked would move the counter under the user on the
|
||||||
|
* step right before it.
|
||||||
|
*
|
||||||
|
* [visibilityArmed] carries the one-time notice that the device is hiding some
|
||||||
|
* calendars (#75) — the only optional step an *existing* install can be given,
|
||||||
|
* which is why it is not gated on `fresh`, and on its own it earns no closing
|
||||||
|
* screen.
|
||||||
|
*/
|
||||||
|
fun onboardingPlan(
|
||||||
|
hasPermission: Boolean,
|
||||||
|
remindersDone: Boolean,
|
||||||
|
wizardArmed: Boolean,
|
||||||
|
backupDone: Boolean,
|
||||||
|
viewDone: Boolean,
|
||||||
|
monthStyleDone: Boolean,
|
||||||
|
backupApplies: Boolean?,
|
||||||
|
visibilityArmed: Boolean = false,
|
||||||
|
visibilityDone: Boolean = false,
|
||||||
|
doneShown: Boolean = false,
|
||||||
|
): OnboardingPlan {
|
||||||
|
val fresh = wizardArmed || (!hasPermission && !remindersDone)
|
||||||
|
val steps = buildList {
|
||||||
|
if (!hasPermission || fresh) add(OnboardingStep.Permission)
|
||||||
|
if (!remindersDone || fresh) add(OnboardingStep.Reminders)
|
||||||
|
if (fresh && backupApplies != false) add(OnboardingStep.Backup)
|
||||||
|
if (fresh) {
|
||||||
|
add(OnboardingStep.View)
|
||||||
|
add(OnboardingStep.MonthStyle)
|
||||||
|
}
|
||||||
|
if (visibilityArmed) add(OnboardingStep.Visibility)
|
||||||
|
if (fresh) add(OnboardingStep.Done)
|
||||||
|
}
|
||||||
|
val current = steps.firstOrNull { step ->
|
||||||
|
when (step) {
|
||||||
|
OnboardingStep.Permission -> !hasPermission
|
||||||
|
OnboardingStep.Reminders -> !remindersDone
|
||||||
|
OnboardingStep.Backup -> !backupDone
|
||||||
|
OnboardingStep.View -> !viewDone
|
||||||
|
OnboardingStep.MonthStyle -> !monthStyleDone
|
||||||
|
OnboardingStep.Visibility -> !visibilityDone
|
||||||
|
OnboardingStep.Done -> !doneShown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return OnboardingPlan(steps, current)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingProgress
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the wizard's current [step] (#163). Each step commits its own answer
|
||||||
|
* through [viewModel], which then hands the host the next one.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun OnboardingSteps(
|
||||||
|
step: OnboardingStep,
|
||||||
|
plan: OnboardingPlan,
|
||||||
|
viewModel: OnboardingViewModel,
|
||||||
|
onPermissionGranted: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
// Collected for every step, not just the two that draw a preview: starting
|
||||||
|
// it when the picker composes would leave that step blank as it slides in.
|
||||||
|
val choice by viewModel.viewChoice.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// Coerced because the outgoing half of a transition may be a step the live
|
||||||
|
// plan has since dropped — the backup step goes once the calendars say it
|
||||||
|
// does not apply — and a "Step 0 of 3" flash is worse than a stale number.
|
||||||
|
val index = (plan.steps.indexOf(step) + 1).coerceAtLeast(1)
|
||||||
|
val progress: (@Composable () -> Unit)? = if (!plan.showsProgress) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
{
|
||||||
|
OnboardingProgress(
|
||||||
|
step = index,
|
||||||
|
total = plan.total,
|
||||||
|
label = stringResource(R.string.onboarding_step_counter, index, plan.total),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The system back gesture does the same thing as the button, and stays off
|
||||||
|
// on the first step so back still leaves the app.
|
||||||
|
BackHandler(enabled = plan.canGoBack) { viewModel.goBack() }
|
||||||
|
val navigationIcon: (@Composable () -> Unit)? = if (!plan.canGoBack) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
{
|
||||||
|
IconButton(onClick = viewModel::goBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.onboarding_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (step) {
|
||||||
|
OnboardingStep.Permission -> PermissionScreen(
|
||||||
|
onGranted = onPermissionGranted,
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
)
|
||||||
|
OnboardingStep.Reminders -> ReminderStep(
|
||||||
|
onFinished = viewModel::finishReminders,
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
)
|
||||||
|
OnboardingStep.Backup -> BackupStep(
|
||||||
|
onEnable = viewModel::enableAutoBackup,
|
||||||
|
onSkip = viewModel::skipBackup,
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
)
|
||||||
|
// The choice is null only until DataStore's first emission; rendering
|
||||||
|
// nothing for that frame beats a preview built on the wrong defaults.
|
||||||
|
OnboardingStep.View -> choice?.let {
|
||||||
|
ViewStep(
|
||||||
|
choice = it,
|
||||||
|
onSelect = viewModel::setDefaultView,
|
||||||
|
onFinished = viewModel::finishView,
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OnboardingStep.MonthStyle -> choice?.let {
|
||||||
|
MonthStyleStep(
|
||||||
|
choice = it,
|
||||||
|
onSelect = viewModel::setMonthViewStyle,
|
||||||
|
onFinished = viewModel::finishMonthStyle,
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OnboardingStep.Visibility -> VisibilityStep(
|
||||||
|
onFinished = viewModel::finishVisibility,
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
)
|
||||||
|
OnboardingStep.Done -> DoneStep(
|
||||||
|
onFinished = viewModel::finishOnboarding,
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
||||||
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.mapLatest
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the first-launch wizard (#163): decides which steps this install owes
|
||||||
|
* (see [onboardingPlan]) and commits each answer.
|
||||||
|
*
|
||||||
|
* [plan] is null until the first stored emission, so the host renders nothing
|
||||||
|
* for that frame rather than flashing a step the user has already answered.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@HiltViewModel
|
||||||
|
class OnboardingViewModel @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val prefs: SettingsPrefs,
|
||||||
|
private val calendarPrefs: CalendarPrefs,
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
private val scanner: ReminderScanner,
|
||||||
|
private val visibilityReconciler: CalendarVisibilityReconciler,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** Null until the host reports it — assuming either way would flash a screen. */
|
||||||
|
private val permissionReported = MutableStateFlow<Boolean?>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The grant as the rest of the flow sees it: published only once the wizard
|
||||||
|
* has been armed for it, since arming is a stored write and the frames
|
||||||
|
* before it lands would collapse the plan to its one-step form.
|
||||||
|
*/
|
||||||
|
private val hasPermission: StateFlow<Boolean?> = permissionReported
|
||||||
|
.mapLatest { granted ->
|
||||||
|
if (granted == true) armWizardIfFresh()
|
||||||
|
granted
|
||||||
|
}
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.Eagerly,
|
||||||
|
initialValue = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the backup step applies: something of yours is worth exporting and
|
||||||
|
* nothing you write to is synced anywhere. Null while the calendar list
|
||||||
|
* cannot be read: every frame before the grant, and any read that fails —
|
||||||
|
* an empty list is also how a null cursor arrives, and guessing false there
|
||||||
|
* would drop the step for the whole session.
|
||||||
|
*/
|
||||||
|
private val backupApplies: Flow<Boolean?> = hasPermission.flatMapLatest { granted ->
|
||||||
|
if (granted != true) {
|
||||||
|
flowOf(null)
|
||||||
|
} else {
|
||||||
|
repository.calendars()
|
||||||
|
.map { calendars -> if (calendars.isEmpty()) null else calendars.backupApplies() }
|
||||||
|
.catch { emit(null) }
|
||||||
|
.flowOn(io)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val flags: Flow<OnboardingFlags> = combine(
|
||||||
|
prefs.reminderOnboardingDone,
|
||||||
|
prefs.onboardingWizardArmed,
|
||||||
|
prefs.onboardingBackupDone,
|
||||||
|
prefs.onboardingViewDone,
|
||||||
|
prefs.onboardingMonthStyleDone,
|
||||||
|
::OnboardingFlags,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The closing steps' own flags, and whether the notice applies at all. */
|
||||||
|
private val closingFlags: Flow<ClosingFlags> = combine(
|
||||||
|
calendarPrefs.visibilityNoticePending.map { it == true },
|
||||||
|
prefs.onboardingVisibilityDone,
|
||||||
|
prefs.onboardingDoneShown,
|
||||||
|
::ClosingFlags,
|
||||||
|
)
|
||||||
|
|
||||||
|
val plan: StateFlow<OnboardingPlan?> =
|
||||||
|
combine(hasPermission, flags, closingFlags, backupApplies) { granted, stored, closing, backup ->
|
||||||
|
granted?.let {
|
||||||
|
onboardingPlan(
|
||||||
|
hasPermission = it,
|
||||||
|
remindersDone = stored.remindersDone,
|
||||||
|
wizardArmed = stored.wizardArmed,
|
||||||
|
backupDone = stored.backupDone,
|
||||||
|
viewDone = stored.viewDone,
|
||||||
|
monthStyleDone = stored.monthStyleDone,
|
||||||
|
backupApplies = backup,
|
||||||
|
visibilityArmed = closing.visibilityArmed,
|
||||||
|
visibilityDone = closing.visibilityDone,
|
||||||
|
doneShown = closing.doneShown,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The current view preferences, for the view step's initial selection. */
|
||||||
|
val viewChoice: StateFlow<ViewChoice?> = combine(
|
||||||
|
prefs.defaultView,
|
||||||
|
prefs.monthViewStyle,
|
||||||
|
prefs.weekStart,
|
||||||
|
::ViewChoice,
|
||||||
|
).stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setHasPermission(granted: Boolean) {
|
||||||
|
permissionReported.value = granted
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arm the wizard for an install that has yet to answer the reminder step,
|
||||||
|
* whatever route the grant took. Gating this on the in-app grant would
|
||||||
|
* strand anyone who granted on Android's own settings screen and came back
|
||||||
|
* to a restarted process, leaving them the reminder step alone.
|
||||||
|
*/
|
||||||
|
private suspend fun armWizardIfFresh() {
|
||||||
|
if (!prefs.reminderOnboardingDone.first()) prefs.armOnboardingWizard()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record the in-app grant. The launch scan ran before it and bailed out
|
||||||
|
* without arming anything, so the first alarm would otherwise wait on the
|
||||||
|
* daily worker.
|
||||||
|
*/
|
||||||
|
fun onPermissionGranted() {
|
||||||
|
scanner.scanInBackground()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the reminder step, recording whether notifications stay on. */
|
||||||
|
fun finishReminders(remindersEnabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setRemindersEnabled(remindersEnabled)
|
||||||
|
prefs.setReminderOnboardingDone()
|
||||||
|
// Nothing else re-arms the scan: turning reminders off cancels the
|
||||||
|
// alarm (#75).
|
||||||
|
scanner.scan()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-ask the step before the current one, by clearing its answer. The plan
|
||||||
|
* derives the position from those answers, so un-setting one *is* the back
|
||||||
|
* navigation. The calendar grant belongs to the system and cannot be
|
||||||
|
* returned to, which [OnboardingPlan.canGoBack] already refuses.
|
||||||
|
*/
|
||||||
|
fun goBack() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (plan.value?.previous) {
|
||||||
|
OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false)
|
||||||
|
OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false)
|
||||||
|
OnboardingStep.View -> prefs.setOnboardingViewDone(false)
|
||||||
|
OnboardingStep.MonthStyle -> prefs.setOnboardingMonthStyleDone(false)
|
||||||
|
OnboardingStep.Visibility -> prefs.setOnboardingVisibilityDone(false)
|
||||||
|
OnboardingStep.Done -> prefs.setOnboardingDoneShown(false)
|
||||||
|
OnboardingStep.Permission, null -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the backup step without setting anything up. */
|
||||||
|
fun skipBackup() {
|
||||||
|
viewModelScope.launch { prefs.setOnboardingBackupDone() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn automatic backup on, writing to the folder the user just picked
|
||||||
|
* (taking a durable write grant so background runs can keep writing), and
|
||||||
|
* close the step.
|
||||||
|
*/
|
||||||
|
fun enableAutoBackup(folder: Uri) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.takePersistableUriPermission(
|
||||||
|
folder,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
prefs.setAutoBackupFolderUri(folder.toString())
|
||||||
|
prefs.setAutoBackupEnabled(true)
|
||||||
|
BackupScheduler.apply(
|
||||||
|
context = context,
|
||||||
|
enabled = true,
|
||||||
|
intervalMinutes = prefs.autoBackupIntervalMinutes.first(),
|
||||||
|
hasFolder = true,
|
||||||
|
)
|
||||||
|
// Immediate feedback: the first backup lands while onboarding runs.
|
||||||
|
BackupScheduler.runNow(context)
|
||||||
|
prefs.setOnboardingBackupDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDefaultView(view: CalendarView) {
|
||||||
|
viewModelScope.launch { prefs.setDefaultView(view) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setMonthViewStyle(style: MonthViewStyle) {
|
||||||
|
viewModelScope.launch { prefs.setMonthViewStyle(style) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close a picker step — the choice itself is saved as it is made. */
|
||||||
|
fun finishView() {
|
||||||
|
viewModelScope.launch { prefs.setOnboardingViewDone() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finishMonthStyle() {
|
||||||
|
viewModelScope.launch { prefs.setOnboardingMonthStyleDone() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Acknowledge the calendar-visibility notice (#75). */
|
||||||
|
fun finishVisibility() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setOnboardingVisibilityDone()
|
||||||
|
// Retiring the notice drops its step, so it waits until nothing
|
||||||
|
// follows — the count must not fall on the way to a later screen.
|
||||||
|
if (plan.value?.steps?.last() == OnboardingStep.Visibility) retireVisibilityNotice()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the wizard from its last screen. */
|
||||||
|
fun finishOnboarding() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.finishOnboardingWizard()
|
||||||
|
retireVisibilityNotice()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put the one-time visibility notice away for good — nothing else clears it
|
||||||
|
* now that the wizard owns it, and left standing it would keep its step in
|
||||||
|
* every later plan.
|
||||||
|
*/
|
||||||
|
private suspend fun retireVisibilityNotice() {
|
||||||
|
if (calendarPrefs.visibilityNoticePending.first() == true) {
|
||||||
|
calendarPrefs.setVisibilityNoticePending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold the retired app-local calendar visibility into the device's own, and
|
||||||
|
* arm the notice if this install has anything to announce. Runs whenever the
|
||||||
|
* app comes up holding the permission, however it was granted — one taken on
|
||||||
|
* Android's app-settings screen never reaches the permission step's callback.
|
||||||
|
*/
|
||||||
|
fun reconcileCalendarVisibility() {
|
||||||
|
viewModelScope.launch { visibilityReconciler.run() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stored answers the plan is derived from. */
|
||||||
|
private data class OnboardingFlags(
|
||||||
|
val remindersDone: Boolean,
|
||||||
|
val wizardArmed: Boolean,
|
||||||
|
val backupDone: Boolean,
|
||||||
|
val viewDone: Boolean,
|
||||||
|
val monthStyleDone: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The tail of the flow: the visibility notice and the closing screen. */
|
||||||
|
private data class ClosingFlags(
|
||||||
|
val visibilityArmed: Boolean,
|
||||||
|
val visibilityDone: Boolean,
|
||||||
|
val doneShown: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** What the view step starts from, and renders its previews with. */
|
||||||
|
data class ViewChoice(
|
||||||
|
val defaultView: CalendarView,
|
||||||
|
val monthViewStyle: MonthViewStyle,
|
||||||
|
val weekStart: WeekStartPref,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when this device holds events that live nowhere else: at least one local
|
||||||
|
* calendar worth exporting, and no writable synced calendar to carry them.
|
||||||
|
* A read-only subscription is not a backup, so it does not count.
|
||||||
|
*/
|
||||||
|
private fun List<CalendarSource>.backupApplies(): Boolean {
|
||||||
|
val exportable = any { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||||
|
val syncedTarget = any { !it.isLocal && it.canModifyContents }
|
||||||
|
return exportable && !syncedTarget
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.permission
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
@@ -10,19 +10,15 @@ import android.net.Uri
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -32,13 +28,11 @@ import androidx.compose.material3.TextButton
|
|||||||
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.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
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
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
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
|
||||||
@@ -52,6 +46,7 @@ private val CALENDAR_PERMISSIONS = arrayOf(
|
|||||||
fun PermissionScreen(
|
fun PermissionScreen(
|
||||||
onGranted: () -> Unit,
|
onGranted: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
progress: (@Composable () -> Unit)? = null,
|
||||||
viewModel: PermissionViewModel = hiltViewModel(),
|
viewModel: PermissionViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
@@ -76,6 +71,7 @@ fun PermissionScreen(
|
|||||||
when (state) {
|
when (state) {
|
||||||
is PermissionUiState.Rationale -> RationaleContent(
|
is PermissionUiState.Rationale -> RationaleContent(
|
||||||
onRequest = { launcher.launch(CALENDAR_PERMISSIONS) },
|
onRequest = { launcher.launch(CALENDAR_PERMISSIONS) },
|
||||||
|
progress = progress,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
is PermissionUiState.Denied -> DeniedContent(
|
is PermissionUiState.Denied -> DeniedContent(
|
||||||
@@ -83,6 +79,7 @@ fun PermissionScreen(
|
|||||||
viewModel.onRetry()
|
viewModel.onRetry()
|
||||||
launcher.launch(CALENDAR_PERMISSIONS)
|
launcher.launch(CALENDAR_PERMISSIONS)
|
||||||
},
|
},
|
||||||
|
progress = progress,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
is PermissionUiState.Granted -> {
|
is PermissionUiState.Granted -> {
|
||||||
@@ -94,11 +91,14 @@ fun PermissionScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun RationaleContent(
|
private fun RationaleContent(
|
||||||
onRequest: () -> Unit,
|
onRequest: () -> Unit,
|
||||||
|
progress: (@Composable () -> Unit)?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
OnboardingScaffold(
|
OnboardingScaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
hero = { BrandHero(denied = false) },
|
progress = progress,
|
||||||
|
topSpacing = OnboardingSpace.lg,
|
||||||
|
hero = { BrandHero() },
|
||||||
actions = {
|
actions = {
|
||||||
Button(
|
Button(
|
||||||
onClick = onRequest,
|
onClick = onRequest,
|
||||||
@@ -116,16 +116,8 @@ private fun RationaleContent(
|
|||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
PrivacyFootnote()
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.app_name).uppercase(),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
letterSpacing = 2.sp,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(OnboardingSpace.xs))
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.permission_rationale_title),
|
text = stringResource(R.string.permission_rationale_title),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
@@ -139,14 +131,8 @@ private fun RationaleContent(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(OnboardingSpace.xl))
|
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||||
|
|
||||||
BenefitRow(
|
|
||||||
icon = Icons.Filled.Lock,
|
|
||||||
title = stringResource(R.string.permission_benefit_private_title),
|
|
||||||
body = stringResource(R.string.permission_benefit_private_body),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
|
||||||
BenefitRow(
|
BenefitRow(
|
||||||
icon = Icons.Filled.CalendarMonth,
|
icon = Icons.Filled.CalendarMonth,
|
||||||
title = stringResource(R.string.permission_benefit_sync_title),
|
title = stringResource(R.string.permission_benefit_sync_title),
|
||||||
@@ -154,9 +140,9 @@ private fun RationaleContent(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
BenefitRow(
|
BenefitRow(
|
||||||
icon = Icons.Filled.VisibilityOff,
|
icon = Icons.Filled.Lock,
|
||||||
title = stringResource(R.string.permission_benefit_privacy_title),
|
title = stringResource(R.string.permission_benefit_private_title),
|
||||||
body = stringResource(R.string.permission_benefit_privacy_body),
|
body = stringResource(R.string.permission_benefit_private_body),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,12 +150,14 @@ private fun RationaleContent(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DeniedContent(
|
private fun DeniedContent(
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
|
progress: (@Composable () -> Unit)?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
OnboardingScaffold(
|
OnboardingScaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
hero = { BrandHero(denied = true) },
|
progress = progress,
|
||||||
|
hero = { BrandHero(HeroBadge.Denied) },
|
||||||
actions = {
|
actions = {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -208,25 +196,3 @@ private fun DeniedContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PrivacyFootnote() {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Lock,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(14.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.permission_privacy_footnote),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.permission
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
sealed interface PermissionUiState {
|
sealed interface PermissionUiState {
|
||||||
data object Rationale : PermissionUiState
|
data object Rationale : PermissionUiState
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.permission
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.permission
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
@@ -8,34 +8,25 @@ import android.Manifest
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.NotificationsActive
|
import androidx.compose.material.icons.filled.NotificationsActive
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Tune
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
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.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-time onboarding step after the calendar grant (v1.4): explains that
|
* Wizard step after the calendar grant (v1.4): explains that
|
||||||
* Calendula delivers reminder notifications itself, warns about duplicates
|
* Calendula delivers reminder notifications itself, warns about duplicates
|
||||||
* when a second calendar app has notifications on, and requests
|
* when a second calendar app has notifications on, and requests
|
||||||
* `POST_NOTIFICATIONS` (a system dialog on API 33+ only; minSdk is 29).
|
* `POST_NOTIFICATIONS` (a system dialog on API 33+ only; minSdk is 29).
|
||||||
@@ -45,9 +36,11 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
* the Settings toggle re-requests it. "Not now" turns the in-app toggle off.
|
* the Settings toggle re-requests it. "Not now" turns the in-app toggle off.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ReminderOnboardingScreen(
|
fun ReminderStep(
|
||||||
onFinished: (remindersEnabled: Boolean) -> Unit,
|
onFinished: (remindersEnabled: Boolean) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
progress: (@Composable () -> Unit)? = null,
|
||||||
|
navigationIcon: (@Composable () -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val launcher = rememberLauncherForActivityResult(
|
val launcher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
@@ -55,7 +48,10 @@ fun ReminderOnboardingScreen(
|
|||||||
|
|
||||||
OnboardingScaffold(
|
OnboardingScaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
hero = { BellHero() },
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
topSpacing = OnboardingSpace.lg,
|
||||||
|
hero = { IconHero(Icons.Filled.NotificationsActive) },
|
||||||
actions = {
|
actions = {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -80,13 +76,6 @@ fun ReminderOnboardingScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.app_name).uppercase(),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
letterSpacing = 2.sp,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(OnboardingSpace.xs))
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.reminder_onboarding_title),
|
text = stringResource(R.string.reminder_onboarding_title),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
@@ -100,7 +89,7 @@ fun ReminderOnboardingScreen(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(OnboardingSpace.xl))
|
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||||
|
|
||||||
BenefitRow(
|
BenefitRow(
|
||||||
icon = Icons.Filled.NotificationsActive,
|
icon = Icons.Filled.NotificationsActive,
|
||||||
@@ -113,30 +102,5 @@ fun ReminderOnboardingScreen(
|
|||||||
title = stringResource(R.string.reminder_benefit_duplicates_title),
|
title = stringResource(R.string.reminder_benefit_duplicates_title),
|
||||||
body = stringResource(R.string.reminder_benefit_duplicates_body),
|
body = stringResource(R.string.reminder_benefit_duplicates_body),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
|
||||||
BenefitRow(
|
|
||||||
icon = Icons.Filled.Tune,
|
|
||||||
title = stringResource(R.string.reminder_benefit_reversible_title),
|
|
||||||
body = stringResource(R.string.reminder_benefit_reversible_body),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A bell in the brand squircle — same silhouette as the permission hero. */
|
|
||||||
@Composable
|
|
||||||
private fun BellHero() {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(128.dp)
|
|
||||||
.clip(RoundedCornerShape(34.dp))
|
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.NotificationsActive,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
modifier = Modifier.size(56.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||||
|
import de.jeanlucmakiola.calendula.ui.day.DayViewPreview
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.WeekViewPreview
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wizard step: which view the app opens on (#163). Built like the Settings
|
||||||
|
* picker it mirrors — a live preview over connected grouped rows. Selecting
|
||||||
|
* applies immediately and leaves the step open; the preview is the confirmation.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ViewStep(
|
||||||
|
choice: ViewChoice,
|
||||||
|
onSelect: (CalendarView) -> Unit,
|
||||||
|
onFinished: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
progress: (@Composable () -> Unit)? = null,
|
||||||
|
navigationIcon: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val weekStart = choice.weekStart.resolveFirstDay(currentLocale())
|
||||||
|
|
||||||
|
StepPickerScaffold(
|
||||||
|
title = stringResource(R.string.onboarding_view_title),
|
||||||
|
onFinished = onFinished,
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
) {
|
||||||
|
StepPreview(selected = choice.defaultView) { view ->
|
||||||
|
when (view) {
|
||||||
|
CalendarView.Month -> MonthStylePreview(
|
||||||
|
style = choice.monthViewStyle,
|
||||||
|
weekStart = weekStart,
|
||||||
|
height = PREVIEW_HEIGHT,
|
||||||
|
)
|
||||||
|
CalendarView.Week -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT)
|
||||||
|
CalendarView.Day -> DayViewPreview(height = PREVIEW_HEIGHT)
|
||||||
|
CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
|
||||||
|
val isSelected = view == choice.defaultView
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(view.labelRes),
|
||||||
|
position = positionOf(index, IMPLEMENTED_VIEWS.size),
|
||||||
|
selected = isSelected,
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
imageVector = view.icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { onSelect(view) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wizard step: how the Month view lays itself out (#163). The same shape as
|
||||||
|
* [ViewStep] and as the Settings chooser this duplicates, down to the selected
|
||||||
|
* style's own blurb sitting under its preview rather than on every row.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun MonthStyleStep(
|
||||||
|
choice: ViewChoice,
|
||||||
|
onSelect: (MonthViewStyle) -> Unit,
|
||||||
|
onFinished: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
progress: (@Composable () -> Unit)? = null,
|
||||||
|
navigationIcon: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val weekStart = choice.weekStart.resolveFirstDay(currentLocale())
|
||||||
|
val options = MonthViewStyle.entries
|
||||||
|
|
||||||
|
StepPickerScaffold(
|
||||||
|
title = stringResource(R.string.onboarding_month_style_title),
|
||||||
|
onFinished = onFinished,
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
) {
|
||||||
|
StepPreview(selected = choice.monthViewStyle) { style ->
|
||||||
|
MonthStylePreview(style = style, weekStart = weekStart, height = PREVIEW_HEIGHT)
|
||||||
|
}
|
||||||
|
PickerDescription(stringResource(choice.monthViewStyle.descriptionRes))
|
||||||
|
options.forEachIndexed { index, style ->
|
||||||
|
val isSelected = style == choice.monthViewStyle
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(style.labelRes),
|
||||||
|
position = positionOf(index, options.size),
|
||||||
|
selected = isSelected,
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { onSelect(style) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shell both picker steps share: the wizard chrome around a title and a
|
||||||
|
* full-bleed column, since grouped rows carry their own inset.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun StepPickerScaffold(
|
||||||
|
title: String,
|
||||||
|
onFinished: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
progress: (@Composable () -> Unit)? = null,
|
||||||
|
navigationIcon: (@Composable () -> Unit)? = null,
|
||||||
|
body: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
OnboardingScaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
topSpacing = OnboardingSpace.xs,
|
||||||
|
contentPadding = 0.dp,
|
||||||
|
// The preview gets the room it needs; Continue scrolls in under the
|
||||||
|
// options rather than pinning to the bottom.
|
||||||
|
scrollingActions = true,
|
||||||
|
hero = {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = GroupedListInset),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = onFinished,
|
||||||
|
// Full-bleed step: the button takes the grouped rows' own inset
|
||||||
|
// and ends up as wide as the options above it.
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = GroupedListInset)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_view_continue_button),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
body = body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live preview, framed like the picker's. The wider gap below keeps the rows
|
||||||
|
* reading as a group of their own rather than as the preview's caption.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun <T> StepPreview(selected: T, content: @Composable (T) -> Unit) {
|
||||||
|
ViewPreviewFrame(
|
||||||
|
selected = selected,
|
||||||
|
label = "step-preview",
|
||||||
|
modifier = Modifier.padding(top = OnboardingSpace.xs, bottom = OnboardingSpace.md),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same size the Settings chooser gives it. */
|
||||||
|
private val PREVIEW_HEIGHT: Dp = 280.dp
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wizard step for the one-time notice that Calendula now follows the device's
|
||||||
|
* own per-calendar visibility and some of yours are switched off (#75, #163).
|
||||||
|
*
|
||||||
|
* Nothing to decide here — the app does not switch those calendars back on,
|
||||||
|
* since that would un-hide them everywhere else too.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun VisibilityStep(
|
||||||
|
onFinished: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
progress: (@Composable () -> Unit)? = null,
|
||||||
|
navigationIcon: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
OnboardingScaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
topSpacing = OnboardingSpace.lg,
|
||||||
|
hero = { IconHero(Icons.Filled.VisibilityOff) },
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = onFinished,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_visibility_button),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.calendars_visibility_notice_title),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.calendars_visibility_notice_message),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.permission
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gates the one-time reminder onboarding step (v1.4) shown after the calendar
|
|
||||||
* grant. [onboardingDone] is null until DataStore's first emission so the
|
|
||||||
* step neither flashes for users who completed it nor gets skipped.
|
|
||||||
*/
|
|
||||||
@HiltViewModel
|
|
||||||
class ReminderOnboardingViewModel @Inject constructor(
|
|
||||||
private val prefs: SettingsPrefs,
|
|
||||||
private val scanner: ReminderScanner,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
|
|
||||||
.map { done -> done as Boolean? }
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Close the step, recording whether reminder notifications stay on. */
|
|
||||||
fun finish(remindersEnabled: Boolean) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
prefs.setRemindersEnabled(remindersEnabled)
|
|
||||||
prefs.setReminderOnboardingDone()
|
|
||||||
// Nothing else re-arms the scan: turning reminders off cancels the
|
|
||||||
// alarm (#75).
|
|
||||||
scanner.scan()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-scan after the calendar permission is granted. The launch scan runs
|
|
||||||
* before the grant and bails out without arming anything, so without this
|
|
||||||
* the first alarm waits on the daily worker.
|
|
||||||
*/
|
|
||||||
fun rearmAfterGrant() {
|
|
||||||
scanner.scanInBackground()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +1,108 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.search
|
package de.jeanlucmakiola.calendula.ui.search
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
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
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.SearchOff
|
import androidx.compose.material.icons.filled.SearchOff
|
||||||
|
import androidx.compose.material.icons.filled.SelectAll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
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.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
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.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
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.domain.MatchSpan
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SearchHit
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SearchMonth
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
|
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
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.floret.components.SnackChip
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||||
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.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import java.time.Instant as JavaInstant
|
import java.time.Instant as JavaInstant
|
||||||
@@ -68,11 +110,13 @@ import java.time.ZoneId
|
|||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
|
/** How long the delete confirmation chip stays up, matching a short snackbar. */
|
||||||
|
private const val CHIP_MILLIS = 4_000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-text event search (top-bar entry). Type a query → matching events
|
* Full-text event search over title / location / description: tap a result to
|
||||||
* (title / location / description) across the whole calendar, newest-relevant
|
* open its detail, long-press to select several and delete them at once (#80).
|
||||||
* first; tap a result to open its detail. A full-screen overlay hosted by
|
* A full-screen overlay hosted by [de.jeanlucmakiola.calendula.ui.CalendarHost].
|
||||||
* [de.jeanlucmakiola.calendula.ui.CalendarHost].
|
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -84,63 +128,79 @@ fun SearchScreen(
|
|||||||
) {
|
) {
|
||||||
val query by viewModel.query.collectAsStateWithLifecycle()
|
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val selection by viewModel.selection.collectAsStateWithLifecycle()
|
||||||
|
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val keyboard = LocalSoftwareKeyboardController.current
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val inSelection = selection.isNotEmpty()
|
||||||
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Each fresh open starts blank and straight into typing. The ViewModel is
|
// The ViewModel is activity-scoped, so a re-enter is what resets the last
|
||||||
// activity-scoped so it outlives the overlay; clearing on (re)enter is what
|
// search, its selection and any delete receipt still up. Peeking a result
|
||||||
// resets a previous search. Peeking a result doesn't re-run this (the screen
|
// doesn't re-run this — the screen stays composed under the detail.
|
||||||
// stays composed under the detail), so backing out keeps the query.
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.setQuery("")
|
viewModel.setQuery("")
|
||||||
|
viewModel.clearSelection()
|
||||||
|
viewModel.consumeDeleteResult()
|
||||||
|
}
|
||||||
|
// The field only exists out of selection mode, so a recreation that lands
|
||||||
|
// mid-selection has to wait for the clear above before it can be focused.
|
||||||
|
var focusPending by remember { mutableStateOf(true) }
|
||||||
|
LaunchedEffect(inSelection) {
|
||||||
|
if (inSelection || !focusPending) return@LaunchedEffect
|
||||||
|
focusPending = false
|
||||||
focusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
keyboard?.show()
|
keyboard?.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same in-place WRITE_CALENDAR upgrade the detail screen does: a v1.0 install
|
||||||
|
// holds only READ_CALENDAR, and granting continues into the held delete.
|
||||||
|
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
|
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
if (granted) pendingWrite?.invoke()
|
||||||
|
pendingWrite = null
|
||||||
|
}
|
||||||
|
val requireWrite: (() -> Unit) -> Unit = { action ->
|
||||||
|
val granted = ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.WRITE_CALENDAR,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
if (granted) {
|
||||||
|
action()
|
||||||
|
} else {
|
||||||
|
pendingWrite = action
|
||||||
|
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back leaves selection mode before it leaves the screen, and without the
|
||||||
|
// predictive-back scale-out — that would read as search closing and snapping
|
||||||
|
// back. The two handlers are mutually exclusive on [inSelection].
|
||||||
|
BackHandler(enabled = inSelection) { viewModel.clearSelection() }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.predictiveBack(onBack = onBack),
|
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection),
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
SearchTopBar(
|
||||||
title = {
|
inSelection = inSelection,
|
||||||
InlineTextField(
|
query = query,
|
||||||
value = query,
|
selectedCount = selection.size,
|
||||||
onValueChange = viewModel::setQuery,
|
focusRequester = focusRequester,
|
||||||
placeholder = stringResource(R.string.search_hint),
|
onQueryChange = viewModel::setQuery,
|
||||||
capitalization = KeyboardCapitalization.None,
|
|
||||||
imeAction = ImeAction.Search,
|
|
||||||
onImeAction = { keyboard?.hide() },
|
onImeAction = { keyboard?.hide() },
|
||||||
modifier = Modifier
|
onBack = onBack,
|
||||||
.fillMaxWidth()
|
onCloseSelection = viewModel::clearSelection,
|
||||||
.focusRequester(focusRequester),
|
onSelectAll = viewModel::selectAll,
|
||||||
)
|
onDelete = { requireWrite { showDeleteDialog = true } },
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = stringResource(R.string.search_back),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
if (query.isNotEmpty()) {
|
|
||||||
IconButton(onClick = { viewModel.setQuery("") }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Close,
|
|
||||||
contentDescription = stringResource(R.string.search_clear),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
// imePadding shrinks the content by the keyboard, so the centered
|
// imePadding so the idle/empty message re-centres above the keyboard and
|
||||||
// idle/empty message re-centres in the space above it (and the results
|
// the results list lifts clear of it.
|
||||||
// list lifts clear of the keyboard too).
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -157,75 +217,413 @@ fun SearchScreen(
|
|||||||
text = stringResource(R.string.search_empty, s.query),
|
text = stringResource(R.string.search_empty, s.query),
|
||||||
)
|
)
|
||||||
is SearchUiState.Results -> SearchResults(
|
is SearchUiState.Results -> SearchResults(
|
||||||
events = s.events,
|
results = s,
|
||||||
|
selection = selection,
|
||||||
|
inSelection = inSelection,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onToggle = viewModel::toggleSelection,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
DeleteOutcomeChip(
|
||||||
|
deleteState = deleteState,
|
||||||
|
onConsume = viewModel::consumeDeleteResult,
|
||||||
|
modifier = Modifier.align(Alignment.BottomStart),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDeleteDialog) {
|
||||||
|
val count = selection.size
|
||||||
|
// One decision for the whole batch: the scope question is only asked when
|
||||||
|
// a recurring event is in it, and one-offs ignore whatever comes back.
|
||||||
|
if (viewModel.selectionHasRecurring()) {
|
||||||
|
RecurringScopeDialog(
|
||||||
|
title = stringResource(R.string.event_delete_recurring_title),
|
||||||
|
onSelect = { scope ->
|
||||||
|
showDeleteDialog = false
|
||||||
|
viewModel.deleteSelected(scope)
|
||||||
|
},
|
||||||
|
onDismiss = { showDeleteDialog = false },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDeleteDialog = false },
|
||||||
|
title = {
|
||||||
|
Text(pluralStringResource(R.plurals.search_delete_title, count, count))
|
||||||
|
},
|
||||||
|
text = { Text(stringResource(R.string.event_delete_body)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showDeleteDialog = false
|
||||||
|
viewModel.deleteSelected(RecurringWriteScope.AllEvents)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_detail_delete),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDeleteDialog = false }) {
|
||||||
|
Text(stringResource(R.string.dialog_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The search field and the contextual selection bar as one bar: the bar stays
|
||||||
|
* in place across the swap, easing its colour and fading its slots through.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun SearchTopBar(
|
||||||
|
inSelection: Boolean,
|
||||||
|
query: String,
|
||||||
|
selectedCount: Int,
|
||||||
|
focusRequester: FocusRequester,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
onImeAction: () -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onCloseSelection: () -> Unit,
|
||||||
|
onSelectAll: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
val container by animateColorAsState(
|
||||||
|
targetValue = if (inSelection) {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
},
|
||||||
|
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||||
|
label = "search-top-bar-color",
|
||||||
|
)
|
||||||
|
// Held past the selection emptying, so the count has something to draw while
|
||||||
|
// it fades out.
|
||||||
|
var lastCount by remember { mutableIntStateOf(0) }
|
||||||
|
if (selectedCount > 0) lastCount = selectedCount
|
||||||
|
|
||||||
|
// Read here so the non-composable transitionSpec lambdas can capture it.
|
||||||
|
val slotSwap = fadeThrough()
|
||||||
|
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = inSelection,
|
||||||
|
transitionSpec = { slotSwap },
|
||||||
|
label = "search-top-bar-title",
|
||||||
|
) { selecting ->
|
||||||
|
if (selecting) {
|
||||||
|
Text(
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.search_selected_count,
|
||||||
|
lastCount,
|
||||||
|
lastCount,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
InlineTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
placeholder = stringResource(R.string.search_hint),
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
imeAction = ImeAction.Search,
|
||||||
|
onImeAction = onImeAction,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = inSelection,
|
||||||
|
transitionSpec = { slotSwap },
|
||||||
|
label = "search-top-bar-navigation",
|
||||||
|
) { selecting ->
|
||||||
|
if (selecting) {
|
||||||
|
IconButton(onClick = onCloseSelection) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.search_selection_close),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.search_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = inSelection,
|
||||||
|
transitionSpec = { slotSwap },
|
||||||
|
label = "search-top-bar-actions",
|
||||||
|
) { selecting ->
|
||||||
|
Row {
|
||||||
|
if (selecting) {
|
||||||
|
IconButton(onClick = onSelectAll) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.SelectAll,
|
||||||
|
contentDescription = stringResource(R.string.search_select_all),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = stringResource(
|
||||||
|
R.string.search_delete_selected,
|
||||||
|
),
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (query.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { onQueryChange("") }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.search_clear),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(containerColor = container),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchResults(
|
private fun SearchResults(
|
||||||
events: List<EventInstance>,
|
results: SearchUiState.Results,
|
||||||
|
selection: Set<Long>,
|
||||||
|
inSelection: Boolean,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onToggle: (Long) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 96.dp),
|
// No horizontal inset: GroupedRow already insets itself.
|
||||||
|
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
results.results.months.forEach { month ->
|
||||||
items = events,
|
stickyHeader(key = "month-${month.year}-${month.monthNumber}") {
|
||||||
key = { _, event -> event.eventId },
|
SearchSectionHeader(text = monthLabel(month))
|
||||||
) { index, event ->
|
}
|
||||||
SearchResultRow(
|
hitRows(month.hits, results, selection, inSelection, onEventClick, onToggle)
|
||||||
event = event,
|
}
|
||||||
position = positionOf(index, events.size),
|
// Held back from the calendar above: matched only in the notes.
|
||||||
modifier = animateItemMotion(),
|
if (results.results.inDescriptions.isNotEmpty()) {
|
||||||
onClick = { onEventClick(event) },
|
stickyHeader(key = "in-descriptions") {
|
||||||
|
SearchSectionHeader(text = stringResource(R.string.search_in_descriptions))
|
||||||
|
}
|
||||||
|
hitRows(
|
||||||
|
results.results.inDescriptions,
|
||||||
|
results,
|
||||||
|
selection,
|
||||||
|
inSelection,
|
||||||
|
onEventClick,
|
||||||
|
onToggle,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One section's rows, sharing a grouped-card run. */
|
||||||
|
private fun LazyListScope.hitRows(
|
||||||
|
hits: List<SearchHit>,
|
||||||
|
results: SearchUiState.Results,
|
||||||
|
selection: Set<Long>,
|
||||||
|
inSelection: Boolean,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onToggle: (Long) -> Unit,
|
||||||
|
) {
|
||||||
|
itemsIndexed(
|
||||||
|
items = hits,
|
||||||
|
key = { _, hit -> hit.event.eventId },
|
||||||
|
) { index, hit ->
|
||||||
|
val event = hit.event
|
||||||
|
val deletable = results.isDeletable(event)
|
||||||
|
SearchResultRow(
|
||||||
|
hit = hit,
|
||||||
|
position = positionOf(index, hits.size),
|
||||||
|
modifier = animateItemMotion(),
|
||||||
|
selected = event.eventId in selection,
|
||||||
|
// A read-only calendar's row can't join a batch, so in selection
|
||||||
|
// mode it goes quiet rather than offering a checkbox that fails.
|
||||||
|
inSelection = inSelection,
|
||||||
|
selectable = deletable,
|
||||||
|
onClick = when {
|
||||||
|
inSelection && deletable -> ({ onToggle(event.eventId) })
|
||||||
|
inSelection -> null
|
||||||
|
else -> ({ onEventClick(event) })
|
||||||
|
},
|
||||||
|
onLongClick = { onToggle(event.eventId) }.takeIf { deletable && !inSelection },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pinned section label, styled like the agenda's day headers. */
|
||||||
|
@Composable
|
||||||
|
private fun SearchSectionHeader(text: String) {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "August 2026" in the locale's own field order. */
|
||||||
|
@Composable
|
||||||
|
private fun monthLabel(month: SearchMonth): String {
|
||||||
|
val locale = currentLocale()
|
||||||
|
return remember(month.year, month.monthNumber, locale) {
|
||||||
|
localizedDateFormatter(locale, "LLLLy")
|
||||||
|
.format(java.time.LocalDate.of(month.year, month.monthNumber, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchResultRow(
|
private fun SearchResultRow(
|
||||||
event: EventInstance,
|
hit: SearchHit,
|
||||||
position: Position,
|
position: Position,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: () -> Unit,
|
selected: Boolean = false,
|
||||||
|
inSelection: Boolean = false,
|
||||||
|
selectable: Boolean = true,
|
||||||
|
onClick: (() -> Unit)?,
|
||||||
|
onLongClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
|
val event = hit.event
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
|
// On a picked row the headline is already recoloured for the secondary
|
||||||
|
// container, so the match is carried by weight alone there.
|
||||||
|
val highlight = SpanStyle(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (selected) Color.Unspecified else MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
modifier = modifier,
|
// Faded like a past event anywhere else in the app — search reaches back
|
||||||
title = event.title,
|
// through the whole history.
|
||||||
summary = searchSummary(event),
|
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
|
||||||
|
title = marked(event.title, hit.titleSpans, highlight),
|
||||||
|
summary = searchSummary(hit, highlight),
|
||||||
position = position,
|
position = position,
|
||||||
minHeight = 64.dp,
|
minHeight = 64.dp,
|
||||||
|
selected = selected,
|
||||||
|
dimmed = inSelection && !selectable,
|
||||||
leading = {
|
leading = {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(width = 6.dp, height = 36.dp)
|
.size(width = 6.dp, height = 36.dp)
|
||||||
.clip(RoundedCornerShape(3.dp))
|
.clip(RoundedCornerShape(3.dp))
|
||||||
.background(eventFill(event.color, dark, soften)),
|
.background(eventAccent(event.color, dark, soften)),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
trailing = if (inSelection && selectable) {
|
||||||
|
{
|
||||||
|
Checkbox(
|
||||||
|
checked = selected,
|
||||||
|
onCheckedChange = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */
|
/**
|
||||||
|
* The batch's receipt: how many went, and whether any refused. No Undo — the
|
||||||
|
* provider can't put a deleted event back.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun searchSummary(event: EventInstance): String {
|
private fun DeleteOutcomeChip(
|
||||||
|
deleteState: BulkDeleteUiState,
|
||||||
|
onConsume: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val message = when (deleteState) {
|
||||||
|
is BulkDeleteUiState.Done -> when {
|
||||||
|
deleteState.failed > 0 -> stringResource(
|
||||||
|
R.string.search_delete_partial,
|
||||||
|
deleteState.deleted,
|
||||||
|
deleteState.failed,
|
||||||
|
)
|
||||||
|
else -> pluralStringResource(
|
||||||
|
R.plurals.search_delete_done,
|
||||||
|
deleteState.deleted,
|
||||||
|
deleteState.deleted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is BulkDeleteUiState.NeedsPermission -> if (deleteState.deleted > 0) {
|
||||||
|
stringResource(R.string.search_delete_denied_partial, deleteState.deleted)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.event_delete_write_denied)
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
// Held past the state being consumed so the chip has something to draw while
|
||||||
|
// it springs back out.
|
||||||
|
val shown = remember { mutableStateOf("") }
|
||||||
|
if (message != null && shown.value != message) shown.value = message
|
||||||
|
|
||||||
|
LaunchedEffect(deleteState) {
|
||||||
|
if (message == null) return@LaunchedEffect
|
||||||
|
delay(CHIP_MILLIS)
|
||||||
|
onConsume()
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(start = SnackChipMargin, bottom = SnackChipMargin)
|
||||||
|
.height(SnackChipHeight),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
SnackChip(visible = message != null, message = shown.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [text] with every matched run emphasised. */
|
||||||
|
private fun marked(text: String, spans: List<MatchSpan>, style: SpanStyle): AnnotatedString =
|
||||||
|
if (spans.isEmpty()) {
|
||||||
|
AnnotatedString(text)
|
||||||
|
} else {
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(text)
|
||||||
|
spans.forEach { addStyle(style, it.start, it.end) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Wed, 17 Jun 2026 · 09:00 · Office", with a second line excerpting the
|
||||||
|
* description when that is where the query matched.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString {
|
||||||
|
val event = hit.event
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val zone = remember { ZoneId.systemDefault() }
|
val zone = remember { ZoneId.systemDefault() }
|
||||||
val start = remember(event.start, zone) {
|
val start = remember(event.start, zone) {
|
||||||
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
|
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
|
||||||
}
|
}
|
||||||
// From the shared span rule, not [start]: an all-day event sits at UTC
|
// From the span rule, not [start]: an all-day event sits at UTC midnight and
|
||||||
// midnight and would name the day before west of UTC (#82). The clock time
|
// would name the day before west of UTC (#82).
|
||||||
// below stays device-zone — it is only rendered for timed events.
|
|
||||||
val dateText = remember(event.start, event.end, event.isAllDay, locale) {
|
val dateText = remember(event.start, event.end, event.isAllDay, locale) {
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
.format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())
|
.format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())
|
||||||
@@ -236,8 +634,23 @@ private fun searchSummary(event: EventInstance): String {
|
|||||||
} else {
|
} else {
|
||||||
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
|
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
|
||||||
}
|
}
|
||||||
val base = "$dateText · $timeText"
|
return buildAnnotatedString {
|
||||||
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
|
append("$dateText · $timeText")
|
||||||
|
event.location?.takeIf { it.isNotBlank() }?.let { location ->
|
||||||
|
append(" · ")
|
||||||
|
val offset = length
|
||||||
|
append(location)
|
||||||
|
hit.locationSpans.forEach {
|
||||||
|
addStyle(highlight, offset + it.start, offset + it.end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hit.descriptionSnippet?.let { snippet ->
|
||||||
|
append("\n")
|
||||||
|
val offset = length
|
||||||
|
append(snippet.text)
|
||||||
|
snippet.spans.forEach { addStyle(highlight, offset + it.start, offset + it.end) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ 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.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventSearch
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SearchResults
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
@@ -14,12 +18,19 @@ import kotlinx.coroutines.flow.SharingStarted
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.mapLatest
|
import kotlinx.coroutines.flow.mapLatest
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -33,8 +44,30 @@ sealed interface SearchUiState {
|
|||||||
/** A query ran but matched nothing. */
|
/** A query ran but matched nothing. */
|
||||||
data class Empty(val query: String) : SearchUiState
|
data class Empty(val query: String) : SearchUiState
|
||||||
|
|
||||||
/** Matches, ordered nearest-to-today first (upcoming ascending, then past descending). */
|
/** Matches by month — see [de.jeanlucmakiola.calendula.domain.EventSearch]. */
|
||||||
data class Results(val events: List<EventInstance>) : SearchUiState
|
data class Results(
|
||||||
|
val results: SearchResults,
|
||||||
|
/** Rows from these are kept out of selection rather than failing later. */
|
||||||
|
val readOnlyCalendarIds: Set<Long> = emptySet(),
|
||||||
|
) : SearchUiState {
|
||||||
|
val events: List<EventInstance> get() = results.allHits.map { it.event }
|
||||||
|
|
||||||
|
fun isDeletable(event: EventInstance): Boolean =
|
||||||
|
event.calendarId !in readOnlyCalendarIds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outcome of deleting the selected results (#80). */
|
||||||
|
sealed interface BulkDeleteUiState {
|
||||||
|
data object Idle : BulkDeleteUiState
|
||||||
|
|
||||||
|
data object Deleting : BulkDeleteUiState
|
||||||
|
|
||||||
|
/** Terminal: [deleted] events went, [failed] wouldn't. */
|
||||||
|
data class Done(val deleted: Int, val failed: Int) : BulkDeleteUiState
|
||||||
|
|
||||||
|
/** WRITE_CALENDAR was revoked after [deleted] went; the rest wasn't tried. */
|
||||||
|
data class NeedsPermission(val deleted: Int) : BulkDeleteUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||||
@@ -47,17 +80,42 @@ class SearchViewModel @Inject constructor(
|
|||||||
private val _query = MutableStateFlow("")
|
private val _query = MutableStateFlow("")
|
||||||
val query: StateFlow<String> = _query.asStateFlow()
|
val query: StateFlow<String> = _query.asStateFlow()
|
||||||
|
|
||||||
val state: StateFlow<SearchUiState> = _query
|
/** Bumped after a delete so the same query re-runs against the changed provider. */
|
||||||
.debounce(250L)
|
private val _reload = MutableStateFlow(0)
|
||||||
.map { it.trim() }
|
|
||||||
.distinctUntilChanged()
|
private val _selection = MutableStateFlow<Set<Long>>(emptySet())
|
||||||
|
|
||||||
|
/** Event ids picked in selection mode; empty means the mode is off. */
|
||||||
|
val selection: StateFlow<Set<Long>> = _selection.asStateFlow()
|
||||||
|
|
||||||
|
private val _deleteState = MutableStateFlow<BulkDeleteUiState>(BulkDeleteUiState.Idle)
|
||||||
|
val deleteState: StateFlow<BulkDeleteUiState> = _deleteState.asStateFlow()
|
||||||
|
|
||||||
|
val state: StateFlow<SearchUiState> = combine(
|
||||||
|
_query.debounce(250L).map { it.trim() }.distinctUntilChanged(),
|
||||||
|
_reload,
|
||||||
|
) { q, _ -> q }
|
||||||
.mapLatest { q ->
|
.mapLatest { q ->
|
||||||
if (q.length < MIN_QUERY_LENGTH) {
|
if (q.length < MIN_QUERY_LENGTH) {
|
||||||
SearchUiState.Idle
|
SearchUiState.Idle
|
||||||
} else {
|
} else {
|
||||||
val results = repository.searchEvents(q)
|
val now = Clock.System.now()
|
||||||
if (results.isEmpty()) SearchUiState.Empty(q)
|
val zone = TimeZone.currentSystemDefault()
|
||||||
else SearchUiState.Results(sortNearestFirst(results))
|
val results = EventSearch.search(
|
||||||
|
candidates = repository.searchEvents(q),
|
||||||
|
query = q,
|
||||||
|
now = now,
|
||||||
|
todayStart = now.toLocalDateTime(zone).date.atStartOfDayIn(zone),
|
||||||
|
zone = zone,
|
||||||
|
)
|
||||||
|
if (results.isEmpty) {
|
||||||
|
SearchUiState.Empty(q)
|
||||||
|
} else {
|
||||||
|
SearchUiState.Results(
|
||||||
|
results = results,
|
||||||
|
readOnlyCalendarIds = readOnlyCalendarIds(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.catch { emit(SearchUiState.Idle) }
|
.catch { emit(SearchUiState.Idle) }
|
||||||
@@ -65,13 +123,94 @@ class SearchViewModel @Inject constructor(
|
|||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle)
|
||||||
|
|
||||||
fun setQuery(value: String) {
|
fun setQuery(value: String) {
|
||||||
|
if (value != _query.value) _selection.value = emptySet()
|
||||||
_query.value = value
|
_query.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Soonest upcoming (and ongoing) first, then the most recent past. */
|
/** Toggle one result; the first pick is what turns selection mode on. */
|
||||||
private fun sortNearestFirst(events: List<EventInstance>): List<EventInstance> {
|
fun toggleSelection(eventId: Long) {
|
||||||
val now = Clock.System.now()
|
val current = _selection.value
|
||||||
val (upcoming, past) = events.partition { it.end >= now }
|
_selection.value = if (eventId in current) current - eventId else current + eventId
|
||||||
return upcoming.sortedBy { it.start } + past.sortedByDescending { it.start }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pick every result that lives in a writable calendar. */
|
||||||
|
fun selectAll() {
|
||||||
|
val results = state.value as? SearchUiState.Results ?: return
|
||||||
|
_selection.value = results.events.filter(results::isDeletable).map { it.eventId }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leave selection mode. */
|
||||||
|
fun clearSelection() {
|
||||||
|
_selection.value = emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the batch needs a [RecurringWriteScope] decision before it runs. */
|
||||||
|
fun selectionHasRecurring(): Boolean {
|
||||||
|
val results = state.value as? SearchUiState.Results ?: return false
|
||||||
|
val picked = _selection.value
|
||||||
|
return results.events.any { it.eventId in picked && it.isRecurring }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete every selected event. [scope] reaches the recurring ones only; a
|
||||||
|
* one-off always goes whole. One at a time, so a single failure doesn't take
|
||||||
|
* the rest with it — the tally lands in [deleteState].
|
||||||
|
*/
|
||||||
|
fun deleteSelected(scope: RecurringWriteScope) {
|
||||||
|
if (_deleteState.value == BulkDeleteUiState.Deleting) return
|
||||||
|
val results = state.value as? SearchUiState.Results ?: return
|
||||||
|
val picked = _selection.value
|
||||||
|
val targets = results.events.filter { it.eventId in picked && results.isDeletable(it) }
|
||||||
|
if (targets.isEmpty()) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_deleteState.value = BulkDeleteUiState.Deleting
|
||||||
|
var deleted = 0
|
||||||
|
var failed = 0
|
||||||
|
var denied = false
|
||||||
|
for (event in targets) {
|
||||||
|
try {
|
||||||
|
withContext(io) { deleteOne(event, scope) }
|
||||||
|
deleted++
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
denied = true
|
||||||
|
break
|
||||||
|
} catch (e: Exception) {
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_selection.value = emptySet()
|
||||||
|
_reload.value += 1
|
||||||
|
_deleteState.value = if (denied) {
|
||||||
|
BulkDeleteUiState.NeedsPermission(deleted = deleted)
|
||||||
|
} else {
|
||||||
|
BulkDeleteUiState.Done(deleted = deleted, failed = failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset [deleteState] after the screen showed the outcome. */
|
||||||
|
fun consumeDeleteResult() {
|
||||||
|
_deleteState.value = BulkDeleteUiState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun deleteOne(event: EventInstance, scope: RecurringWriteScope) {
|
||||||
|
val begin = event.start.toEpochMilliseconds()
|
||||||
|
when {
|
||||||
|
!event.isRecurring -> repository.deleteEvent(event.eventId)
|
||||||
|
scope == RecurringWriteScope.ThisEvent ->
|
||||||
|
repository.deleteOccurrence(event.eventId, begin)
|
||||||
|
scope == RecurringWriteScope.ThisAndFollowing ->
|
||||||
|
repository.deleteEventFromOccurrence(event.eventId, begin)
|
||||||
|
else -> repository.deleteEvent(event.eventId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun readOnlyCalendarIds(): Set<Long> =
|
||||||
|
repository.calendars().first()
|
||||||
|
.filterNot { it.canModifyContents }
|
||||||
|
.map { it.id }
|
||||||
|
.toSet()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,39 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.Keyboard
|
import androidx.compose.material.icons.filled.Keyboard
|
||||||
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.durationLabel
|
||||||
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.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
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.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
|
|
||||||
/** New event form: which fields it opens with, and how it behaves. */
|
/** New event form: which fields it opens with, and how it behaves. */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -27,6 +42,13 @@ internal fun EventFormScreen(
|
|||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
var showDefaultDuration by remember { mutableStateOf(false) }
|
||||||
|
// The calendar whose duration-override picker is open, if any.
|
||||||
|
var durationTarget by remember { mutableStateOf<Long?>(null) }
|
||||||
|
var durationSectionExpanded by remember { mutableStateOf(false) }
|
||||||
|
// Special-dates calendars carry all-day events only, which have no length.
|
||||||
|
val durationCalendars = state.writableCalendars.filterNot { it.id in state.managedCalendarIds }
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_event_form),
|
title = stringResource(R.string.settings_section_event_form),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
@@ -80,6 +102,64 @@ internal fun EventFormScreen(
|
|||||||
onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) },
|
onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// How long a new timed event opens (#54), globally and per calendar —
|
||||||
|
// the per-calendar list folds behind its own header, like the reminders.
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_event_duration),
|
||||||
|
summary = durationLabel(state.defaultEventDurationMinutes),
|
||||||
|
position = if (durationCalendars.isEmpty()) Position.Alone else Position.Top,
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Schedule,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { showDefaultDuration = true },
|
||||||
|
)
|
||||||
|
if (durationCalendars.isNotEmpty()) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_calendar_durations_title),
|
||||||
|
summary = stringResource(R.string.settings_calendar_durations_hint),
|
||||||
|
// Expanded, the header opens the calendar run below it and the
|
||||||
|
// two read as one container (the shared expand pattern).
|
||||||
|
position = if (durationSectionExpanded) Position.Top else Position.Bottom,
|
||||||
|
trailing = {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (durationSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { durationSectionExpanded = !durationSectionExpanded },
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = durationSectionExpanded,
|
||||||
|
enter = expandEnter(),
|
||||||
|
exit = collapseExit(),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
durationCalendars.forEachIndexed { index, calendar ->
|
||||||
|
val override = state.perCalendarEventDuration[calendar.id]
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
summary = override?.let { durationLabel(it) }
|
||||||
|
?: stringResource(
|
||||||
|
R.string.settings_calendar_duration_inherits,
|
||||||
|
durationLabel(state.defaultEventDurationMinutes),
|
||||||
|
),
|
||||||
|
// The header above is the run's first row, so the
|
||||||
|
// calendars continue it: middles, then its bottom.
|
||||||
|
position = positionOf(index + 1, durationCalendars.size + 1),
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
onClick = { durationTarget = calendar.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Per-event colour on calendars that publish no colour set (some
|
// Per-event colour on calendars that publish no colour set (some
|
||||||
// CalDAV); off by default, since it may not survive their next sync.
|
// CalDAV); off by default, since it may not survive their next sync.
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
@@ -100,4 +180,36 @@ internal fun EventFormScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showDefaultDuration) {
|
||||||
|
DurationPicker(
|
||||||
|
title = stringResource(R.string.settings_event_duration),
|
||||||
|
description = stringResource(R.string.settings_event_duration_hint),
|
||||||
|
presets = EVENT_DURATION_PRESETS,
|
||||||
|
selected = state.defaultEventDurationMinutes,
|
||||||
|
label = { durationLabel(it) },
|
||||||
|
// No inherit row on the global default, so a pick is never null.
|
||||||
|
onSelect = { minutes -> minutes?.let { viewModel.setDefaultEventDuration(it) } },
|
||||||
|
onDismiss = { showDefaultDuration = false },
|
||||||
|
maxMinutes = SettingsPrefs.MAX_EVENT_DURATION,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
durationTarget?.let { calendarId ->
|
||||||
|
DurationPicker(
|
||||||
|
title = stringResource(R.string.settings_event_duration),
|
||||||
|
presets = EVENT_DURATION_PRESETS,
|
||||||
|
selected = state.perCalendarEventDuration[calendarId],
|
||||||
|
label = { durationLabel(it) },
|
||||||
|
inheritLabel = stringResource(
|
||||||
|
R.string.settings_calendar_duration_use_default,
|
||||||
|
durationLabel(state.defaultEventDurationMinutes),
|
||||||
|
),
|
||||||
|
onSelect = { viewModel.setCalendarEventDuration(calendarId, it) },
|
||||||
|
onDismiss = { durationTarget = null },
|
||||||
|
maxMinutes = SettingsPrefs.MAX_EVENT_DURATION,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lengths offered for a new timed event, in minutes — up to a full work day. */
|
||||||
|
private val EVENT_DURATION_PRESETS = listOf(15, 30, 45, 60, 90, 120, 240, 480)
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
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.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
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.clipToBounds
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
||||||
@@ -27,7 +16,6 @@ import de.jeanlucmakiola.floret.components.FullScreenPicker
|
|||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,40 +39,22 @@ internal fun MonthViewStylePicker(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val options = MonthViewStyle.entries
|
val options = MonthViewStyle.entries
|
||||||
val reduceMotion = rememberReduceMotion()
|
|
||||||
FullScreenPicker(
|
FullScreenPicker(
|
||||||
title = stringResource(R.string.settings_month_view_style),
|
title = stringResource(R.string.settings_month_view_style),
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
Box(
|
ViewPreviewFrame(
|
||||||
modifier = Modifier
|
selected = selected,
|
||||||
.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",
|
label = "month-style-preview",
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
) { shown ->
|
) { 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(
|
MonthStylePreview(
|
||||||
style = shown,
|
style = shown,
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
height = PREVIEW_HEIGHT,
|
height = PREVIEW_HEIGHT,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
// The selected style's own blurb lives here, under its preview, rather
|
// 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
|
// than on every row — the rows stay single-line and dense, and the words
|
||||||
// describe exactly what is being shown above.
|
// describe exactly what is being shown above.
|
||||||
@@ -109,4 +79,3 @@ internal fun MonthViewStylePicker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val PREVIEW_HEIGHT = 280.dp
|
private val PREVIEW_HEIGHT = 280.dp
|
||||||
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -38,10 +37,11 @@ import androidx.lifecycle.LifecycleEventObserver
|
|||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
|
||||||
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.SnoozeDurationPicker
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.durationLabel
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
@@ -146,7 +146,7 @@ internal fun NotificationsScreen(
|
|||||||
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_snooze_duration),
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
summary = snoozeDurationLabel(state.snoozeMinutes),
|
summary = durationLabel(state.snoozeMinutes),
|
||||||
position = Position.Bottom,
|
position = Position.Bottom,
|
||||||
onClick = { showSnooze = true },
|
onClick = { showSnooze = true },
|
||||||
)
|
)
|
||||||
@@ -245,12 +245,13 @@ internal fun NotificationsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showSnooze) {
|
if (showSnooze) {
|
||||||
SnoozeDurationPicker(
|
DurationPicker(
|
||||||
title = stringResource(R.string.settings_snooze_duration),
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
presets = SNOOZE_PRESETS,
|
presets = SNOOZE_PRESETS,
|
||||||
selected = state.snoozeMinutes,
|
selected = state.snoozeMinutes,
|
||||||
label = { snoozeDurationLabel(it) },
|
label = { durationLabel(it) },
|
||||||
onSelect = { viewModel.setSnoozeMinutes(it) },
|
// No inherit row here, so a pick is never null.
|
||||||
|
onSelect = { minutes -> minutes?.let { viewModel.setSnoozeMinutes(it) } },
|
||||||
onDismiss = { showSnooze = false },
|
onDismiss = { showSnooze = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -348,15 +349,6 @@ private fun calendarOverrideSummary(
|
|||||||
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
|
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
|
||||||
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
||||||
|
|
||||||
/** A snooze delay as a plain duration ("10 minutes", "1 hour") — no "before". */
|
|
||||||
@Composable
|
|
||||||
private fun snoozeDurationLabel(minutes: Int): String =
|
|
||||||
if (minutes % 60 == 0) {
|
|
||||||
pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60)
|
|
||||||
} else {
|
|
||||||
pluralStringResource(R.plurals.duration_minutes, minutes, minutes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether Calendula is exempt from battery optimisation, re-read on every
|
* Whether Calendula is exempt from battery optimisation, re-read on every
|
||||||
* `ON_RESUME` so a change made in system settings shows up at once.
|
* `ON_RESUME` so a change made in system settings shows up at once.
|
||||||
|
|||||||
@@ -302,13 +302,14 @@ private fun ReportProblemRow(position: Position) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun LanguageRow(position: Position) {
|
private fun LanguageRow(position: Position) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val supported = remember { AppLanguage.supportedTags(context, R.xml.locales_config) }
|
||||||
// Setting a locale recreates the activity; mirror the choice locally so the
|
// Setting a locale recreates the activity; mirror the choice locally so the
|
||||||
// row updates instantly even before the recreation lands.
|
// row updates instantly even before the recreation lands.
|
||||||
var current by remember { mutableStateOf(AppLanguage.currentTag()) }
|
var current by remember { mutableStateOf(AppLanguage.currentTag(supported)) }
|
||||||
var showDialog by remember { mutableStateOf(false) }
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
||||||
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context, R.xml.locales_config) }
|
val options = remember(supported) { listOf<String?>(null) + supported }
|
||||||
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_language),
|
title = stringResource(R.string.settings_language),
|
||||||
|
|||||||
@@ -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.common.TimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||||
|
|
||||||
@@ -41,6 +42,8 @@ data class SettingsUiState(
|
|||||||
val showWeekNumbers: Boolean = false,
|
val showWeekNumbers: Boolean = false,
|
||||||
/** Whether the jump-to-today control sits in the top bar instead of the FAB (#60). */
|
/** Whether the jump-to-today control sits in the top bar instead of the FAB (#60). */
|
||||||
val todayButtonInToolbar: Boolean = false,
|
val todayButtonInToolbar: Boolean = false,
|
||||||
|
/** Whether events can be dragged to another slot to reschedule them (#68, #173). */
|
||||||
|
val dragToReschedule: Boolean = true,
|
||||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
/** 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). */
|
||||||
@@ -57,12 +60,18 @@ data class SettingsUiState(
|
|||||||
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
|
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
|
||||||
/** How the Month view lays itself out: pages, continuous scroll, or split (#38, #53). */
|
/** How the Month view lays itself out: pages, continuous scroll, or split (#38, #53). */
|
||||||
val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged,
|
val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged,
|
||||||
|
/** How tall an hour is drawn in the week and day timelines (#56). */
|
||||||
|
val timelineScale: TimelineScale = TimelineScale.Regular,
|
||||||
/** 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"). */
|
||||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||||
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
|
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
|
||||||
val autofocusEventTitle: Boolean = true,
|
val autofocusEventTitle: Boolean = true,
|
||||||
|
/** How long a new timed event lasts, in minutes (#54); all-day events ignore it. */
|
||||||
|
val defaultEventDurationMinutes: Int = SettingsPrefs.DEFAULT_EVENT_DURATION,
|
||||||
|
/** Per-calendar overrides of [defaultEventDurationMinutes]; absent = inherit. */
|
||||||
|
val perCalendarEventDuration: Map<Long, Int> = emptyMap(),
|
||||||
/** Whether Calendula posts reminder notifications (v1.4). */
|
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||||
val remindersEnabled: Boolean = true,
|
val remindersEnabled: Boolean = true,
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -36,6 +36,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.common.TimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
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
|
||||||
@@ -123,8 +124,14 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.perCalendarAllDayReminderOverride,
|
prefs.perCalendarAllDayReminderOverride,
|
||||||
writableCalendars,
|
writableCalendars,
|
||||||
prefs.managedCalendarIds,
|
prefs.managedCalendarIds,
|
||||||
) { overrides, allDayOverrides, calendars, managedIds ->
|
// The new-event duration defaults (#54) fold into one flow so they
|
||||||
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds)
|
// fit this group — the outer combine is at its five-arg limit.
|
||||||
|
combine(
|
||||||
|
prefs.defaultEventDurationMinutes,
|
||||||
|
prefs.perCalendarEventDuration,
|
||||||
|
) { duration, perCalendar -> EventDurations(duration, perCalendar) },
|
||||||
|
) { overrides, allDayOverrides, calendars, managedIds, durations ->
|
||||||
|
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds, durations)
|
||||||
},
|
},
|
||||||
combine(
|
combine(
|
||||||
prefs.defaultView,
|
prefs.defaultView,
|
||||||
@@ -138,9 +145,15 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.showWeekNumbers,
|
prefs.showWeekNumbers,
|
||||||
prefs.agendaShowToday,
|
prefs.agendaShowToday,
|
||||||
prefs.softenCalendarColors,
|
prefs.softenCalendarColors,
|
||||||
|
combine(
|
||||||
prefs.todayButtonInToolbar,
|
prefs.todayButtonInToolbar,
|
||||||
) { hourLines, weekNumbers, showToday, soften, todayInToolbar ->
|
prefs.dragToReschedule,
|
||||||
DisplayToggles(hourLines, weekNumbers, showToday, soften, todayInToolbar)
|
::Pair,
|
||||||
|
),
|
||||||
|
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove) ->
|
||||||
|
DisplayToggles(
|
||||||
|
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
||||||
ViewSettings(
|
ViewSettings(
|
||||||
@@ -150,6 +163,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
agendaShowToday = toggles.agendaShowToday,
|
agendaShowToday = toggles.agendaShowToday,
|
||||||
softenColors = toggles.softenColors,
|
softenColors = toggles.softenColors,
|
||||||
todayButtonInToolbar = toggles.todayButtonInToolbar,
|
todayButtonInToolbar = toggles.todayButtonInToolbar,
|
||||||
|
dragToReschedule = toggles.dragToReschedule,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
combine(
|
combine(
|
||||||
@@ -164,8 +178,9 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.drawerViewOrder,
|
prefs.drawerViewOrder,
|
||||||
prefs.monthViewStyle,
|
prefs.monthViewStyle,
|
||||||
prefs.widgetSize,
|
prefs.widgetSize,
|
||||||
) { quickSwitch, drawer, monthStyle, widgetSize ->
|
prefs.timelineScale,
|
||||||
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize)
|
) { quickSwitch, drawer, monthStyle, widgetSize, timelineScale ->
|
||||||
|
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize, timelineScale)
|
||||||
},
|
},
|
||||||
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
||||||
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
||||||
@@ -181,6 +196,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
agendaShowToday = views.agendaShowToday,
|
agendaShowToday = views.agendaShowToday,
|
||||||
softenColors = views.softenColors,
|
softenColors = views.softenColors,
|
||||||
todayButtonInToolbar = views.todayButtonInToolbar,
|
todayButtonInToolbar = views.todayButtonInToolbar,
|
||||||
|
dragToReschedule = views.dragToReschedule,
|
||||||
agendaShowRangeBar = misc.showRangeBar,
|
agendaShowRangeBar = misc.showRangeBar,
|
||||||
autofocusEventTitle = misc.autofocusEventTitle,
|
autofocusEventTitle = misc.autofocusEventTitle,
|
||||||
pastEventDisplay = misc.pastEventDisplay,
|
pastEventDisplay = misc.pastEventDisplay,
|
||||||
@@ -189,6 +205,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
||||||
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
||||||
widgetSize = misc.viewCustomization.widgetSize,
|
widgetSize = misc.viewCustomization.widgetSize,
|
||||||
|
timelineScale = misc.viewCustomization.timelineScale,
|
||||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
@@ -198,6 +215,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
perCalendarAllDayReminderOverride = overrides.allDay,
|
perCalendarAllDayReminderOverride = overrides.allDay,
|
||||||
writableCalendars = overrides.calendars,
|
writableCalendars = overrides.calendars,
|
||||||
managedCalendarIds = overrides.managedIds,
|
managedCalendarIds = overrides.managedIds,
|
||||||
|
defaultEventDurationMinutes = overrides.durations.default,
|
||||||
|
perCalendarEventDuration = overrides.durations.perCalendar,
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@@ -260,6 +279,12 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val allDay: Map<Long, List<Int>>,
|
val allDay: Map<Long, List<Int>>,
|
||||||
val calendars: List<CalendarSource>,
|
val calendars: List<CalendarSource>,
|
||||||
val managedIds: Set<Long>,
|
val managedIds: Set<Long>,
|
||||||
|
val durations: EventDurations,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class EventDurations(
|
||||||
|
val default: Int,
|
||||||
|
val perCalendar: Map<Long, Int>,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class ViewSettings(
|
private data class ViewSettings(
|
||||||
@@ -272,6 +297,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val agendaShowToday: Boolean,
|
val agendaShowToday: Boolean,
|
||||||
val softenColors: Boolean,
|
val softenColors: Boolean,
|
||||||
val todayButtonInToolbar: Boolean,
|
val todayButtonInToolbar: Boolean,
|
||||||
|
val dragToReschedule: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class DisplayToggles(
|
private data class DisplayToggles(
|
||||||
@@ -280,6 +306,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val agendaShowToday: Boolean,
|
val agendaShowToday: Boolean,
|
||||||
val softenColors: Boolean,
|
val softenColors: Boolean,
|
||||||
val todayButtonInToolbar: Boolean,
|
val todayButtonInToolbar: Boolean,
|
||||||
|
val dragToReschedule: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class MiscSettings(
|
private data class MiscSettings(
|
||||||
@@ -295,6 +322,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val drawerOrder: List<CalendarView>,
|
val drawerOrder: List<CalendarView>,
|
||||||
val monthViewStyle: MonthViewStyle,
|
val monthViewStyle: MonthViewStyle,
|
||||||
val widgetSize: WidgetSize,
|
val widgetSize: WidgetSize,
|
||||||
|
val timelineScale: TimelineScale,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 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. */
|
||||||
@@ -516,6 +544,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) }
|
viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setDragToReschedule(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setDragToReschedule(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switch the launcher label (#44). The card highlights at once, then settles
|
* Switch the launcher label (#44). The card highlights at once, then settles
|
||||||
* on what the component state reports; the writes are binder round-trips and
|
* on what the component state reports; the writes are binder round-trips and
|
||||||
@@ -581,6 +613,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setMonthViewStyle(style) }
|
viewModelScope.launch { prefs.setMonthViewStyle(style) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setTimelineScale(scale: TimelineScale) {
|
||||||
|
viewModelScope.launch { prefs.setTimelineScale(scale) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setDrawerViewOrder(order: List<CalendarView>) {
|
fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||||
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||||
}
|
}
|
||||||
@@ -600,6 +636,15 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
|
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setDefaultEventDuration(minutes: Int) {
|
||||||
|
viewModelScope.launch { prefs.setDefaultEventDurationMinutes(minutes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [minutes] null clears the override, so the calendar inherits the default. */
|
||||||
|
fun setCalendarEventDuration(calendarId: Long, minutes: Int?) {
|
||||||
|
viewModelScope.launch { prefs.setCalendarEventDuration(calendarId, minutes) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setDefaultReminderMinutes(minutes: List<Int>) {
|
fun setDefaultReminderMinutes(minutes: List<Int>) {
|
||||||
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ 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.PickerDescription
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.descriptionRes
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||||
@@ -68,6 +70,7 @@ internal fun ViewsScreen(
|
|||||||
var showTimeFormat by remember { mutableStateOf(false) }
|
var showTimeFormat by remember { mutableStateOf(false) }
|
||||||
var showPastEvents by remember { mutableStateOf(false) }
|
var showPastEvents by remember { mutableStateOf(false) }
|
||||||
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||||
|
var showTimelineScale by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_views),
|
title = stringResource(R.string.settings_section_views),
|
||||||
@@ -111,7 +114,7 @@ internal fun ViewsScreen(
|
|||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_dim_completed),
|
title = stringResource(R.string.settings_dim_completed),
|
||||||
summary = stringResource(R.string.settings_dim_completed_summary),
|
summary = stringResource(R.string.settings_dim_completed_summary),
|
||||||
position = Position.Bottom,
|
position = Position.Middle,
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = state.dimCompletedEvents,
|
checked = state.dimCompletedEvents,
|
||||||
@@ -120,6 +123,18 @@ internal fun ViewsScreen(
|
|||||||
},
|
},
|
||||||
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
|
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_drag_to_reschedule),
|
||||||
|
summary = stringResource(R.string.settings_drag_to_reschedule_summary),
|
||||||
|
position = Position.Bottom,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.dragToReschedule,
|
||||||
|
onCheckedChange = viewModel::setDragToReschedule,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setDragToReschedule(!state.dragToReschedule) },
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
SectionHeader(stringResource(R.string.settings_month_header))
|
SectionHeader(stringResource(R.string.settings_month_header))
|
||||||
@@ -144,10 +159,16 @@ internal fun ViewsScreen(
|
|||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
SectionHeader(stringResource(R.string.settings_week_day_header))
|
SectionHeader(stringResource(R.string.settings_week_day_header))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_timeline_scale),
|
||||||
|
summary = stringResource(state.timelineScale.labelRes),
|
||||||
|
position = Position.Top,
|
||||||
|
onClick = { showTimelineScale = true },
|
||||||
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_hour_lines),
|
title = stringResource(R.string.settings_hour_lines),
|
||||||
summary = stringResource(R.string.settings_hour_lines_summary),
|
summary = stringResource(R.string.settings_hour_lines_summary),
|
||||||
position = Position.Alone,
|
position = Position.Bottom,
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = state.showHourLines,
|
checked = state.showHourLines,
|
||||||
@@ -321,6 +342,25 @@ internal fun ViewsScreen(
|
|||||||
onDismiss = { showPastEvents = false },
|
onDismiss = { showPastEvents = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (showTimelineScale) {
|
||||||
|
OptionPicker(
|
||||||
|
title = stringResource(R.string.settings_timeline_scale),
|
||||||
|
header = { PickerDescription(stringResource(R.string.settings_timeline_scale_hint)) },
|
||||||
|
predictiveBack = true,
|
||||||
|
// A height pinched on the timeline is listed alongside the presets
|
||||||
|
// rather than left as a silently unticked list: it is the current
|
||||||
|
// setting, so it has to be visible here, and seeing it next to the
|
||||||
|
// named steps is what makes "tap one to go back" obvious.
|
||||||
|
options = TimelineScale.presets + listOfNotNull(
|
||||||
|
state.timelineScale as? TimelineScale.Custom,
|
||||||
|
),
|
||||||
|
selected = state.timelineScale,
|
||||||
|
label = { stringResource(it.labelRes) },
|
||||||
|
summary = { stringResource(it.descriptionRes) },
|
||||||
|
onSelect = viewModel::setTimelineScale,
|
||||||
|
onDismiss = { showTimelineScale = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
if (showAgendaScreenRange) {
|
if (showAgendaScreenRange) {
|
||||||
AgendaRangePicker(
|
AgendaRangePicker(
|
||||||
title = stringResource(R.string.settings_agenda_range),
|
title = stringResource(R.string.settings_agenda_range),
|
||||||
|
|||||||
@@ -1,33 +1,21 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
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.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
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.clipToBounds
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
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.data.prefs.resolveFirstDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
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.SelectedCheck
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,39 +34,23 @@ internal fun WeekStartPicker(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val reduceMotion = rememberReduceMotion()
|
|
||||||
val resolved = selected.resolveFirstDay(locale)
|
val resolved = selected.resolveFirstDay(locale)
|
||||||
FullScreenPicker(
|
FullScreenPicker(
|
||||||
title = stringResource(R.string.settings_week_start),
|
title = stringResource(R.string.settings_week_start),
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
Box(
|
ViewPreviewFrame(
|
||||||
modifier = Modifier
|
selected = resolved,
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
|
||||||
.height(PREVIEW_HEIGHT),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Crossfade(
|
|
||||||
targetState = resolved,
|
|
||||||
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
|
||||||
label = "week-start-preview",
|
label = "week-start-preview",
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
) { day ->
|
) { day ->
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(PREVIEW_SHAPE)
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.clipToBounds(),
|
|
||||||
) {
|
|
||||||
MonthStylePreview(
|
MonthStylePreview(
|
||||||
style = monthStyle,
|
style = monthStyle,
|
||||||
weekStart = day,
|
weekStart = day,
|
||||||
height = PREVIEW_HEIGHT,
|
height = PREVIEW_HEIGHT,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
PickerDescription(stringResource(R.string.settings_week_start_hint))
|
PickerDescription(stringResource(R.string.settings_week_start_hint))
|
||||||
options.forEachIndexed { index, option ->
|
options.forEachIndexed { index, option ->
|
||||||
val isSelected = option == selected
|
val isSelected = option == selected
|
||||||
@@ -107,4 +79,3 @@ internal fun WeekStartPicker(
|
|||||||
|
|
||||||
/** Short enough to leave the first options on screen under it. */
|
/** Short enough to leave the first options on screen under it. */
|
||||||
private val PREVIEW_HEIGHT = 200.dp
|
private val PREVIEW_HEIGHT = 200.dp
|
||||||
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
|
||||||
|
|||||||
@@ -47,10 +47,12 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.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.key
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
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
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -62,13 +64,17 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.customActions
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -77,12 +83,28 @@ 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.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
||||||
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.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.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.beginsOn
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
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
|
||||||
@@ -97,9 +119,18 @@ import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
|||||||
import de.jeanlucmakiola.floret.locale.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.LocalTimelineZoom
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||||
@@ -113,20 +144,14 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
private val HOUR_HEIGHT = 56.dp
|
|
||||||
private val GUTTER_WIDTH = 48.dp
|
|
||||||
/** Start inset for the gutter's content (week badge + hour labels) so it centres
|
|
||||||
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp
|
|
||||||
* (the app bar's 4dp inset + 24dp half icon button). */
|
|
||||||
private val GUTTER_CONTENT_START_INSET = 8.dp
|
|
||||||
private val MIN_EVENT_HEIGHT = 24.dp
|
|
||||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
|
/** Gap between day columns; part of the column pitch a drag maps positions through. */
|
||||||
|
private val COLUMN_GAP = 2.dp
|
||||||
|
|
||||||
/** Total all-day strip height for a week (0 when there are no all-day events). */
|
/** Total all-day strip height for a week (0 when there are no all-day events). */
|
||||||
private fun WeekUiState.Success.allDayStripHeight(): Dp {
|
internal fun WeekUiState.Success.allDayStripHeight(): Dp {
|
||||||
if (allDaySpans.isEmpty()) return 0.dp
|
if (allDaySpans.isEmpty()) return 0.dp
|
||||||
val lanes = allDaySpans.maxOf { it.lane } + 1
|
val lanes = allDaySpans.maxOf { it.lane } + 1
|
||||||
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
||||||
@@ -216,6 +241,7 @@ fun WeekScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = weekStart,
|
currentDate = weekStart,
|
||||||
|
drawerState = drawerState,
|
||||||
viewOrder = drawerViewOrder,
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
@@ -242,6 +268,7 @@ fun WeekScreen(
|
|||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
|
onJumpToDate = jumpToDate,
|
||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
@@ -294,7 +321,6 @@ private fun WeekContent(
|
|||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
@@ -306,11 +332,9 @@ private fun WeekContent(
|
|||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||||
val maxV = scrollState.maxValue
|
// Half the scroll range *is* noon: the content spans a full 24 hours, so
|
||||||
val target = with(density) {
|
// centring the range centres midday at whatever hour height is in force.
|
||||||
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
|
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||||
}.coerceIn(0, maxV)
|
|
||||||
scrollState.scrollTo(target)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
||||||
@@ -328,9 +352,16 @@ private fun WeekContent(
|
|||||||
// gestures coexist without fighting.
|
// gestures coexist without fighting.
|
||||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||||
|
|
||||||
|
// Above the AnimatedContent: a page change mid-drag would strand the
|
||||||
|
// floating block inside the outgoing page.
|
||||||
|
val dragController = rememberTimelineDragController()
|
||||||
|
val move = LocalEventMove.current
|
||||||
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
|
|
||||||
|
Box(modifier = modifier) {
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = state,
|
targetState = state,
|
||||||
modifier = modifier.then(swipeModifier),
|
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
||||||
contentKey = { s ->
|
contentKey = { s ->
|
||||||
when (s) {
|
when (s) {
|
||||||
is WeekUiState.Success -> "success-${s.weekStart}"
|
is WeekUiState.Success -> "success-${s.weekStart}"
|
||||||
@@ -349,23 +380,41 @@ private fun WeekContent(
|
|||||||
topSectionColor = topSectionColor,
|
topSectionColor = topSectionColor,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = allDayHeight,
|
allDayHeight = allDayHeight,
|
||||||
|
dragController = dragController,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
|
onDrop = { drop ->
|
||||||
|
val took = move?.move(
|
||||||
|
MoveRequest(
|
||||||
|
eventId = drop.event.eventId,
|
||||||
|
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||||
|
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||||
|
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Refused, so nothing will land: let the copy go now
|
||||||
|
// rather than hold it out for a settle that never comes.
|
||||||
|
if (took != true) dragController.release()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
TimelineDragOverlay(dragController)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeekSuccess(
|
internal fun WeekSuccess(
|
||||||
state: WeekUiState.Success,
|
state: WeekUiState.Success,
|
||||||
topSectionColor: Color,
|
topSectionColor: Color,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
|
dragController: TimelineDragController,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(
|
Column(
|
||||||
@@ -382,8 +431,10 @@ private fun WeekSuccess(
|
|||||||
Timeline(
|
Timeline(
|
||||||
state = state,
|
state = state,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
|
dragController = dragController,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
|
onDrop = onDrop,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -397,6 +448,7 @@ private fun WeekTopBar(
|
|||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
showTodayButton: Boolean,
|
showTodayButton: Boolean,
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
@@ -404,9 +456,10 @@ private fun WeekTopBar(
|
|||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
CalendarTitleButton(
|
||||||
text = formatWeekTitle(weekStart, locale, currentYear),
|
title = formatWeekTitle(weekStart, locale, currentYear),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
currentDate = weekStart,
|
||||||
|
onJumpToDate = onJumpToDate,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -617,62 +670,67 @@ private fun AllDayBar(
|
|||||||
private fun Timeline(
|
private fun Timeline(
|
||||||
state: WeekUiState.Success,
|
state: WeekUiState.Success,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
|
dragController: TimelineDragController,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
) {
|
) {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
val zoom = LocalTimelineZoom.current
|
||||||
val locale = currentLocale()
|
val density = LocalDensity.current
|
||||||
|
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
|
||||||
|
// timeline's own viewport height, which is only known here — below the top
|
||||||
|
// bar, day header and all-day strip.
|
||||||
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val hourHeight = zoom.scale.hourHeight(maxHeight)
|
||||||
|
val totalHeight = hourHeight * 24
|
||||||
|
// The pinch sits on the Row, above both scroll viewports: it has to
|
||||||
|
// outrank the vertical scroll, and it does that by watching the initial
|
||||||
|
// pass, which only reaches it if it is their ancestor.
|
||||||
|
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
|
||||||
// Gutter and day columns are two scroll viewports that SHARE one scroll
|
// Gutter and day columns are two scroll viewports that SHARE one scroll
|
||||||
// state, so they stay perfectly aligned. The day-column viewport is a
|
// state, so they stay perfectly aligned. The day-column viewport is a
|
||||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||||
// soft corners are permanent at any scroll position (not just at the
|
// soft corners are permanent at any scroll position (not just at the
|
||||||
// day's start/end).
|
// day's start/end).
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
||||||
// Hour gutter (scrolls in sync with the day columns). Same start inset
|
// Hour gutter (scrolls in sync with the day columns). Same start inset
|
||||||
// as the header badge so the labels sit under it and on the hamburger.
|
// as the header badge so the labels sit under it and on the hamburger.
|
||||||
Column(
|
HourGutter(
|
||||||
modifier = Modifier
|
scrollState = scrollState,
|
||||||
.width(GUTTER_WIDTH)
|
hourHeight = hourHeight,
|
||||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
dragController = dragController,
|
||||||
.fillMaxHeight()
|
|
||||||
.verticalScroll(scrollState),
|
|
||||||
) {
|
|
||||||
(0 until 24).forEach { h ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(HOUR_HEIGHT),
|
|
||||||
) {
|
|
||||||
if (h > 0) {
|
|
||||||
Text(
|
|
||||||
text = formatHourLabel(h, use24Hour, locale),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.offset(y = (-6).dp),
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Day columns: rounded, clipped scroll viewport (permanent corners).
|
// Day columns: rounded, clipped scroll viewport (permanent corners).
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState)
|
||||||
|
.onGloballyPositioned { dragController.geometry.viewport = it },
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(totalHeight),
|
.height(totalHeight)
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
// The scrolling content itself, so its root position
|
||||||
|
// already folds in the scroll offset.
|
||||||
|
.onGloballyPositioned { coords ->
|
||||||
|
val gap = with(density) { COLUMN_GAP.toPx() }
|
||||||
|
dragController.geometry.let {
|
||||||
|
it.grid = coords
|
||||||
|
it.scroll = scrollState
|
||||||
|
it.hourPx = with(density) { hourHeight.toPx() }
|
||||||
|
it.columnGapPx = gap
|
||||||
|
it.columnWidthPx = (coords.size.width + gap) / state.days.size
|
||||||
|
it.days = state.days
|
||||||
|
it.isRtl = isRtl
|
||||||
|
}
|
||||||
|
},
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP),
|
||||||
) {
|
) {
|
||||||
state.days.forEach { day ->
|
state.days.forEach { day ->
|
||||||
DayColumnCard(
|
DayColumnCard(
|
||||||
@@ -680,8 +738,11 @@ private fun Timeline(
|
|||||||
dark = dark,
|
dark = dark,
|
||||||
date = day,
|
date = day,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
|
hourHeight = hourHeight,
|
||||||
|
dragController = dragController,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
|
onDrop = onDrop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
@@ -699,13 +760,26 @@ private fun DayColumnCard(
|
|||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
|
hourHeight: Dp,
|
||||||
|
dragController: TimelineDragController,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
||||||
val showHourLines = LocalShowHourLines.current
|
val showHourLines = LocalShowHourLines.current
|
||||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||||
|
// Tells a settled drop when this column has caught up with it.
|
||||||
|
LaunchedEffect(blocks, dragController.settling) {
|
||||||
|
dragController.noteGrid(date, blocks)
|
||||||
|
}
|
||||||
|
// The tap detector outlives the composition that installed it — a pinch or a
|
||||||
|
// Settings change moves the hour height without restarting it — so it reads
|
||||||
|
// the height and the callback through state handles instead of capturing
|
||||||
|
// them, or taps land on the scale the column had before the zoom (#148).
|
||||||
|
val currentHourPx = rememberUpdatedState(hourPx)
|
||||||
|
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
|
||||||
Card(
|
Card(
|
||||||
// Plain rectangular columns — the soft corners come from the outer
|
// Plain rectangular columns — the soft corners come from the outer
|
||||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||||
@@ -725,32 +799,52 @@ private fun DayColumnCard(
|
|||||||
// blocks are consumed by their own handler first. Snaps to hour.
|
// blocks are consumed by their own handler first. Snaps to hour.
|
||||||
.pointerInput(date) {
|
.pointerInput(date) {
|
||||||
detectTapGestures { offset ->
|
detectTapGestures { offset ->
|
||||||
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
|
currentOnCreateAt.value(
|
||||||
onCreateAt(date, hour * 60)
|
date,
|
||||||
|
tappedMinuteOfDay(offset.y, currentHourPx.value),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
val colWidth = maxWidth
|
val colWidth = maxWidth
|
||||||
|
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
||||||
|
// Keyed by event, so a block that changes time or lane is the *same*
|
||||||
|
// composable afterwards and tweens there. The ordinal disambiguates
|
||||||
|
// a column holding two occurrences of one series.
|
||||||
|
val ordinals = mutableMapOf<Long, Int>()
|
||||||
blocks.forEach { block ->
|
blocks.forEach { block ->
|
||||||
|
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
||||||
|
key(block.event.eventId, ordinal) {
|
||||||
val laneWidth = colWidth / block.laneCount
|
val laneWidth = colWidth / block.laneCount
|
||||||
val top = HOUR_HEIGHT * (block.startMin / 60f)
|
val top = hourHeight * (block.startMin / 60f)
|
||||||
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f)
|
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||||
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight
|
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||||
|
val place = animatedBlockPlacement(
|
||||||
|
x = laneWidth * block.lane,
|
||||||
|
y = top,
|
||||||
|
width = laneWidth,
|
||||||
|
height = height,
|
||||||
|
)
|
||||||
EventBlock(
|
EventBlock(
|
||||||
block = block,
|
block = block,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
height = height,
|
height = place.height,
|
||||||
|
width = place.width,
|
||||||
|
date = date,
|
||||||
|
dragController = dragController,
|
||||||
onClick = { onEventClick(block.event) },
|
onClick = { onEventClick(block.event) },
|
||||||
|
onDrop = onDrop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(x = laneWidth * block.lane, y = top)
|
.offset(x = place.x, y = place.y)
|
||||||
.width(laneWidth)
|
.width(place.width)
|
||||||
.height(height)
|
.height(place.height)
|
||||||
.padding(horizontal = 1.dp),
|
.padding(horizontal = 1.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Current-time line, on top of the events, only on today's column.
|
// Current-time line, on top of the events, only on today's column.
|
||||||
if (date == today) {
|
if (date == today) {
|
||||||
NowLine(date = date, hourHeight = HOUR_HEIGHT)
|
NowLine(date = date, hourHeight = hourHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -761,7 +855,11 @@ private fun EventBlock(
|
|||||||
block: TimedBlock,
|
block: TimedBlock,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
height: Dp,
|
height: Dp,
|
||||||
|
width: Dp,
|
||||||
|
date: LocalDate,
|
||||||
|
dragController: TimelineDragController,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
@@ -769,10 +867,6 @@ private fun EventBlock(
|
|||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||||
minToHm(block.endMin, use24Hour, locale)
|
minToHm(block.endMin, use24Hour, locale)
|
||||||
// Only full-width (non-overlapping) blocks that are tall enough show the time.
|
|
||||||
// On narrow overlapping columns we drop it so the title can wrap to fill the
|
|
||||||
// whole block, mirroring Google Calendar.
|
|
||||||
val showTime = block.endMin - block.startMin >= 45 && block.laneCount == 1
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val titleLineHeight = with(density) {
|
val titleLineHeight = with(density) {
|
||||||
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||||
@@ -780,23 +874,66 @@ private fun EventBlock(
|
|||||||
val timeLineHeight = with(density) {
|
val timeLineHeight = with(density) {
|
||||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||||
}
|
}
|
||||||
// Wrap the title across as many lines as the block can fit (minus the 2.dp
|
// What's left for text once the 2.dp top/bottom padding is paid for.
|
||||||
// top/bottom padding and the reserved time line) instead of clipping it to a
|
val available = height - 4.dp
|
||||||
// single character on slim, overlapping blocks.
|
// Only full-width (non-overlapping) blocks that are tall enough show the
|
||||||
val contentHeight = height - 4.dp - if (showTime) timeLineHeight else 0.dp
|
// time. On narrow overlapping columns we drop it so the title can wrap to
|
||||||
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
// fill the whole block, mirroring Google Calendar — and a block that cannot
|
||||||
|
// afford both lines spends its space on the title.
|
||||||
|
val showTime = block.endMin - block.startMin >= 45 &&
|
||||||
|
block.laneCount == 1 &&
|
||||||
|
available >= titleLineHeight + timeLineHeight
|
||||||
|
// A short block drops the title rather than serving a horizontally sliced
|
||||||
|
// one: half a letter reads as a rendering fault, while a bare colour chip
|
||||||
|
// reads as what it is — an event too brief to label. Tap still opens it, and
|
||||||
|
// the semantics description carries the full title either way.
|
||||||
|
val showTitle = available >= titleLineHeight
|
||||||
|
// Wrap the title across as many lines as the block can fit — but only once a
|
||||||
|
// line is wide enough to hold more than a syllable. Below that the extra
|
||||||
|
// lines just stack fragments of the word, and one ellipsised line reads
|
||||||
|
// better.
|
||||||
|
val contentHeight = available - if (showTime) timeLineHeight else 0.dp
|
||||||
|
val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
(contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||||
|
}
|
||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
val fill = eventFill(block.event.color, dark, soften)
|
val fill = eventFill(block.event.color, dark, soften)
|
||||||
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
|
val moveAction = eventMoveAction(block.event)
|
||||||
|
// A block clipped at the top continues from the previous day: its top edge
|
||||||
|
// is midnight, not the event's start, so dragging it would invent a time.
|
||||||
|
val draggable = eventDragAllowed(block.event) && block.beginsOn(date, zone)
|
||||||
|
val dragModifier = rememberEventDragSource(
|
||||||
|
enabled = draggable,
|
||||||
|
key = block.event.instanceId,
|
||||||
|
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
|
||||||
|
onMove = dragController::move,
|
||||||
|
onDrop = { dragController.finish()?.let(onDrop) },
|
||||||
|
onCancel = dragController::cancel,
|
||||||
|
)
|
||||||
|
val lifted = draggable && dragController.ghosts(block, date)
|
||||||
|
val ghost = ghostAlpha(lifted)
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
|
// The source stays put as a ghost while its floating copy travels.
|
||||||
|
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||||
.background(fill, RoundedCornerShape(4.dp))
|
.background(fill, RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
|
// After clickable, so it is the inner node and wins the main pass;
|
||||||
|
// the tap still works, since a drag consumes the up.
|
||||||
|
.then(dragModifier)
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = "$title, $timeLabel" },
|
.semantics {
|
||||||
|
contentDescription = "$title, $timeLabel"
|
||||||
|
if (moveAction != null) customActions = listOf(moveAction)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
|
if (showTitle) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
@@ -804,13 +941,11 @@ private fun EventBlock(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = eventInk(fill, alpha = 0.85f),
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
if (showTime) {
|
if (showTime) {
|
||||||
Text(
|
BlockTimeLabel(
|
||||||
text = timeLabel,
|
label = timeLabel,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
color = eventInk(fill, alpha = 0.6f),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -819,7 +954,7 @@ private fun EventBlock(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeekLoading() {
|
private fun WeekLoading() {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
val scale = LocalTimelineZoom.current.scale
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Header skeleton
|
// Header skeleton
|
||||||
@@ -838,6 +973,10 @@ private fun WeekLoading() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Same scale resolution as the loaded timeline, so the skeleton's
|
||||||
|
// columns don't resize the moment the real week arrives.
|
||||||
|
val totalHeight = scale.hourHeight(maxHeight) * 24
|
||||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||||
repeat(7) {
|
repeat(7) {
|
||||||
@@ -851,6 +990,7 @@ 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 =
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.sampleTimelineEvents
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A live, scaled-down Week view for the onboarding view chooser. The week and
|
||||||
|
* today's column are real; only the events are stand-ins.
|
||||||
|
*
|
||||||
|
* The timeline is scrolled to the working day rather than to midnight — the
|
||||||
|
* live view centres on noon once it knows its own scroll range, which a preview
|
||||||
|
* that is never interacted with would otherwise never do.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun WeekViewPreview(
|
||||||
|
weekStart: DayOfWeek,
|
||||||
|
height: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
|
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||||
|
val state = remember(today, weekStart, zone) { sampleWeekState(today, weekStart, zone) }
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||||
|
// Half the scroll range is noon — the same centring the live view does.
|
||||||
|
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
ScaledViewPreview(height = height, modifier = modifier) {
|
||||||
|
WeekSuccess(
|
||||||
|
state = state,
|
||||||
|
topSectionColor = MaterialTheme.colorScheme.surface,
|
||||||
|
scrollState = scrollState,
|
||||||
|
allDayHeight = state.allDayStripHeight(),
|
||||||
|
dragController = rememberTimelineDragController(),
|
||||||
|
onEventClick = {},
|
||||||
|
onOpenDay = {},
|
||||||
|
onCreateAt = { _, _ -> },
|
||||||
|
onDrop = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sample week state, laid out through the same helpers the live view uses. */
|
||||||
|
private fun sampleWeekState(
|
||||||
|
today: LocalDate,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
|
zone: TimeZone,
|
||||||
|
): WeekUiState.Success {
|
||||||
|
val start = today.startOfWeek(weekStart)
|
||||||
|
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
|
||||||
|
val events = sampleTimelineEvents(days, today, zone)
|
||||||
|
return WeekUiState.Success(
|
||||||
|
weekStart = start,
|
||||||
|
today = today,
|
||||||
|
days = days,
|
||||||
|
allDaySpans = layoutAllDay(events.filter { it.isAllDay }, days, zone),
|
||||||
|
timedByDay = days.associateWith { layoutDay(events, it, zone) },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -24,7 +24,6 @@ import androidx.glance.appwidget.cornerRadius
|
|||||||
import androidx.glance.appwidget.lazy.LazyColumn
|
import androidx.glance.appwidget.lazy.LazyColumn
|
||||||
import androidx.glance.appwidget.lazy.items
|
import androidx.glance.appwidget.lazy.items
|
||||||
import androidx.glance.appwidget.provideContent
|
import androidx.glance.appwidget.provideContent
|
||||||
import androidx.glance.appwidget.updateAll
|
|
||||||
import androidx.glance.background
|
import androidx.glance.background
|
||||||
import androidx.glance.currentState
|
import androidx.glance.currentState
|
||||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
@@ -58,6 +57,7 @@ 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.floret.locale.localizedDateFormatter
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
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
|
||||||
@@ -134,10 +134,14 @@ class AgendaWidget : GlanceAppWidget() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-reads the calendar and redraws the widget (header refresh button). */
|
/**
|
||||||
|
* Re-reads the calendar and redraws the widget (header refresh button). Targets
|
||||||
|
* the tapped widget's own id rather than `updateAll`, whose provider-name lookup
|
||||||
|
* is empty in a process a tap woke from cold — see `ShiftMonthAction` (#18).
|
||||||
|
*/
|
||||||
class RefreshAgendaAction : ActionCallback {
|
class RefreshAgendaAction : ActionCallback {
|
||||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
AgendaWidget().updateAll(context.applicationContext)
|
AgendaWidget().update(context.applicationContext, glanceId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,7 +386,7 @@ private fun EventRow(
|
|||||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
|
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
|
||||||
// dropping both text lines to the lower-emphasis on-surface-variant tone.
|
// dropping both text lines to the lower-emphasis on-surface-variant tone.
|
||||||
val stripeColor = eventFill(event.color, dark, soften).let {
|
val stripeColor = eventAccent(event.color, dark, soften).let {
|
||||||
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
||||||
}
|
}
|
||||||
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
|
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import androidx.glance.appwidget.action.actionStartActivity
|
|||||||
import androidx.glance.appwidget.cornerRadius
|
import androidx.glance.appwidget.cornerRadius
|
||||||
import androidx.glance.appwidget.provideContent
|
import androidx.glance.appwidget.provideContent
|
||||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||||
import androidx.glance.appwidget.updateAll
|
|
||||||
import androidx.glance.background
|
import androidx.glance.background
|
||||||
import androidx.glance.currentState
|
import androidx.glance.currentState
|
||||||
import androidx.glance.layout.Alignment
|
import androidx.glance.layout.Alignment
|
||||||
@@ -118,7 +117,17 @@ class MonthWidget : GlanceAppWidget() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Step the displayed month by the `delta` action parameter (±1). */
|
/**
|
||||||
|
* Step the displayed month by the `delta` action parameter (±1).
|
||||||
|
*
|
||||||
|
* Redrawn through [GlanceAppWidget.update] with the id the callback was handed,
|
||||||
|
* never `updateAll`. `updateAll` resolves its targets through the provider-name
|
||||||
|
* -> app-widget-id map Glance persists for the *receiver*, and in a process that
|
||||||
|
* has done nothing else yet — the one a tap wakes after a reboot — that lookup
|
||||||
|
* comes back empty and the redraw is dropped: the state write lands, nothing
|
||||||
|
* recomposes, and the arrows read as dead until something else opens the app and
|
||||||
|
* starts a session (#18). The tapped widget's own id needs no lookup.
|
||||||
|
*/
|
||||||
class ShiftMonthAction : ActionCallback {
|
class ShiftMonthAction : ActionCallback {
|
||||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
val delta = parameters[deltaKey] ?: 0
|
val delta = parameters[deltaKey] ?: 0
|
||||||
@@ -126,7 +135,7 @@ class ShiftMonthAction : ActionCallback {
|
|||||||
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
|
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
|
||||||
prefs[MONTH_INDEX_KEY] = cur + delta
|
prefs[MONTH_INDEX_KEY] = cur + delta
|
||||||
}
|
}
|
||||||
MonthWidget().updateAll(context.applicationContext)
|
MonthWidget().update(context.applicationContext, glanceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -134,11 +143,11 @@ class ShiftMonthAction : ActionCallback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Jump the displayed month back to the current month. */
|
/** Jump the displayed month back to the current month. See [ShiftMonthAction]. */
|
||||||
class ResetMonthAction : ActionCallback {
|
class ResetMonthAction : ActionCallback {
|
||||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) }
|
updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) }
|
||||||
MonthWidget().updateAll(context.applicationContext)
|
MonthWidget().update(context.applicationContext, glanceId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,54 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Monochrome status-bar mark: Material "event" calendar glyph. -->
|
<!--
|
||||||
|
Status-bar mark for reminders (issue #83). Calendula's own mark, not the
|
||||||
|
stock Material "event" glyph it replaces: the status bar draws a small icon
|
||||||
|
from its alpha channel alone, so the silhouette is the only thing that can
|
||||||
|
tell Calendula apart from the system's date surfaces and every other
|
||||||
|
calendar app.
|
||||||
|
|
||||||
|
Redrawn for 24dp from design/icon/calendula_notification.svg rather than
|
||||||
|
scaled down from the launcher mark — the numeral "1" is dropped and the
|
||||||
|
bloom's petals are filled instead of stroked, both of which turn to mush at
|
||||||
|
this size. Stays opaque white on transparent; any colour here is discarded.
|
||||||
|
-->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="24dp"
|
android:width="24dp"
|
||||||
android:height="24dp"
|
android:height="24dp"
|
||||||
android:viewportWidth="24"
|
android:viewportWidth="24"
|
||||||
android:viewportHeight="24"
|
android:viewportHeight="24">
|
||||||
android:tint="?attr/colorControlNormal">
|
<!-- Calendar body, left open at the bottom-right where the bloom sits -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="@android:color/white"
|
android:strokeColor="#FFFFFFFF"
|
||||||
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM19,19H5V8h14V19zM7,10h5v5H7V10z" />
|
android:strokeWidth="1.9"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M1.4,5.4H18.6M18.6,9.43V4.7C18.6,2.99 17.21,1.6 15.5,1.6H4.5C2.79,1.6 1.4,2.99 1.4,4.7V15.7C1.4,17.41 2.79,18.8 4.5,18.8H9.63" />
|
||||||
|
<!-- Calendula bloom: eight filled petals around a filled centre -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M17.6,16.35Q20.42,18.65 23.25,16.35Q20.42,14.05 17.6,16.35Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M17.26,17.16Q17.63,20.79 21.26,21.16Q20.89,17.53 17.26,17.16Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M16.45,17.5Q14.15,20.33 16.45,23.15Q18.75,20.33 16.45,17.5Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M15.64,17.16Q12.01,17.53 11.64,21.16Q15.27,20.79 15.64,17.16Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M15.3,16.35Q12.47,14.05 9.65,16.35Q12.47,18.65 15.3,16.35Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M15.64,15.54Q15.27,11.91 11.64,11.54Q12.01,15.17 15.64,15.54Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M16.45,15.2Q18.75,12.38 16.45,9.55Q14.15,12.38 16.45,15.2Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M17.26,15.54Q20.89,15.17 21.26,11.54Q17.63,11.91 17.26,15.54Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M16.45,13.85A2.5,2.5 0 1,1 16.45,18.85A2.5,2.5 0 1,1 16.45,13.85Z" />
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
<string name="state_failure_no_calendars_action">فتح إعدادات تقويم النظام</string>
|
<string name="state_failure_no_calendars_action">فتح إعدادات تقويم النظام</string>
|
||||||
<string name="state_failure_provider">تعذّر قراءة التقويم.</string>
|
<string name="state_failure_provider">تعذّر قراءة التقويم.</string>
|
||||||
<string name="permission_rationale_title">شاهد جميع أحداثك، بشكل جميل</string>
|
<string name="permission_rationale_title">شاهد جميع أحداثك، بشكل جميل</string>
|
||||||
<string name="permission_rationale_body">Calendula يحتاج للوصول إلى تقويمك لعرض و إدارة أحداثك. هذا كل ما يطلبه مقدمًا — ولا يخرج أي شيء من جهازك على الإطلاق.</string>
|
<string name="permission_rationale_body">Calendula يحتاج للوصول إلى تقويمك لعرض و إدارة أحداثك.</string>
|
||||||
<string name="permission_request_button">منح الوصول إلى التقويم</string>
|
<string name="permission_request_button">منح الوصول إلى التقويم</string>
|
||||||
<string name="permission_denied_title">رُفِض الوصول إلى التقويم</string>
|
<string name="permission_denied_title">رُفِض الوصول إلى التقويم</string>
|
||||||
<string name="permission_denied_body">Calendula لا يمكنه عرض الأحداث بدون الوصول إلى التقويم. يمكنك منحه مُجددًا في إعدادات النظام.</string>
|
<string name="permission_denied_body">Calendula لا يمكنه عرض الأحداث بدون الوصول إلى التقويم. يمكنك منحه مُجددًا في إعدادات النظام.</string>
|
||||||
<string name="permission_retry_button">أعِد المحاولة</string>
|
<string name="permission_retry_button">أعِد المحاولة</string>
|
||||||
<string name="permission_open_settings_button">فتح إعدادات النظام</string>
|
<string name="permission_open_settings_button">فتح إعدادات النظام</string>
|
||||||
<string name="permission_benefit_private_title">يبقى على جهازك</string>
|
<string name="permission_benefit_private_title">الخصوصية حسب التصميم</string>
|
||||||
<string name="permission_benefit_private_body">تقويماتك تتِم قراءتها محليًا ولا تُغادر الهاتف أبدًا.</string>
|
<string name="permission_benefit_private_body">لا يوجد إذن بالإنترنت، ولا تتبع عن بُعد — لا شيء يغادر هاتفك أبدًا.</string>
|
||||||
<string name="permission_benefit_sync_title">جميع تقويماتك، مع بعض</string>
|
<string name="permission_benefit_sync_title">جميع تقويماتك، مع بعض</string>
|
||||||
<string name="permission_benefit_sync_body">جوجل، CalDAV، محلي — أي شيء تمت مزامنته مع الجهاز يظهر فورًا.</string>
|
<string name="permission_benefit_sync_body">جوجل، CalDAV، محلي — أي شيء تمت مزامنته مع الجهاز يظهر فورًا.</string>
|
||||||
<string name="permission_benefit_privacy_title">لا تتبع، أبدًا</string>
|
<string name="permission_benefit_privacy_title">لا تتبع، أبدًا</string>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<string name="month_prev">الشهر السابق</string>
|
<string name="month_prev">الشهر السابق</string>
|
||||||
<string name="month_next">الشهر القادم</string>
|
<string name="month_next">الشهر القادم</string>
|
||||||
<string name="month_today_action">اليوم</string>
|
<string name="month_today_action">اليوم</string>
|
||||||
<string name="month_action_settings">إعدادات</string>
|
<string name="month_action_settings">الإعدادات</string>
|
||||||
<string name="settings_title">الإعدادات</string>
|
<string name="settings_title">الإعدادات</string>
|
||||||
<string name="settings_theme">الثيم</string>
|
<string name="settings_theme">الثيم</string>
|
||||||
<string name="settings_theme_system">النظام</string>
|
<string name="settings_theme_system">النظام</string>
|
||||||
@@ -126,6 +126,7 @@
|
|||||||
<string name="event_edit_recurrence_none">لا يتكرر</string>
|
<string name="event_edit_recurrence_none">لا يتكرر</string>
|
||||||
<string name="import_reminder_prompt_body_none">تم استيراد هذا الحدث بدون أي تذكير.</string>
|
<string name="import_reminder_prompt_body_none">تم استيراد هذا الحدث بدون أي تذكير.</string>
|
||||||
<plurals name="import_reminder_prompt_body_existing">
|
<plurals name="import_reminder_prompt_body_existing">
|
||||||
|
<item quantity="zero">تم استيراد هذا الحدث مع (%1$d) لا تذكيرات.</item>
|
||||||
<item quantity="one">تم استيراد هذا الحدث مع %1$d تذكير.</item>
|
<item quantity="one">تم استيراد هذا الحدث مع %1$d تذكير.</item>
|
||||||
<item quantity="two">تم استيراد هذا الحدث مع %1$d تذكيران.</item>
|
<item quantity="two">تم استيراد هذا الحدث مع %1$d تذكيران.</item>
|
||||||
<item quantity="few">تم استيراد هذا الحدث مع %1$d تذكيرات.</item>
|
<item quantity="few">تم استيراد هذا الحدث مع %1$d تذكيرات.</item>
|
||||||
@@ -137,7 +138,7 @@
|
|||||||
<string name="event_detail_calendar">التقويم</string>
|
<string name="event_detail_calendar">التقويم</string>
|
||||||
<string name="event_detail_calendar_unknown">تقويم غير معروف</string>
|
<string name="event_detail_calendar_unknown">تقويم غير معروف</string>
|
||||||
<string name="event_detail_description">الوصف</string>
|
<string name="event_detail_description">الوصف</string>
|
||||||
<string name="event_detail_all_day">كل يوم</string>
|
<string name="event_detail_all_day">طوال اليوم</string>
|
||||||
<string name="event_detail_location">الموقع</string>
|
<string name="event_detail_location">الموقع</string>
|
||||||
<string name="event_detail_attendees">الحضور</string>
|
<string name="event_detail_attendees">الحضور</string>
|
||||||
<string name="event_detail_recurrence">التكرار</string>
|
<string name="event_detail_recurrence">التكرار</string>
|
||||||
@@ -177,7 +178,7 @@
|
|||||||
<string name="reminder_channel_name">تذكيرات الحدث</string>
|
<string name="reminder_channel_name">تذكيرات الحدث</string>
|
||||||
<string name="reminder_channel_description">الإشعارات في أوقات التذكير الخاصه بأحداثك</string>
|
<string name="reminder_channel_description">الإشعارات في أوقات التذكير الخاصه بأحداثك</string>
|
||||||
<string name="reminder_onboarding_title">لا تفوّت أي حدث أبدًا</string>
|
<string name="reminder_onboarding_title">لا تفوّت أي حدث أبدًا</string>
|
||||||
<string name="reminder_onboarding_body">Android لا يعرض تذكيرات الأحداث من نفسه — بل يجب أن يقوم تطبيق تقويم بذلك. دع Calendula يتولى هذه المهمة.</string>
|
<string name="reminder_onboarding_body">أندرويد لا يعرض تذكيرات الأحداث من نفسه — بل يجب أن يقوم تطبيق تقويم بذلك. دع Calendula يتولى هذه المهمة.</string>
|
||||||
<string name="reminder_benefit_delivery_body">كل تذكير لأحداثك يصِل كإشعار، في الوقت المحدد تمامًا.</string>
|
<string name="reminder_benefit_delivery_body">كل تذكير لأحداثك يصِل كإشعار، في الوقت المحدد تمامًا.</string>
|
||||||
<string name="reminder_benefit_duplicates_title">هل تستخدم تطبيق تقويم ثانٍ؟</string>
|
<string name="reminder_benefit_duplicates_title">هل تستخدم تطبيق تقويم ثانٍ؟</string>
|
||||||
<string name="reminder_benefit_duplicates_body">إذا تطبيق آخر أيضًا ينشر تذكيرات، فستراهم مرتين — قم بإيقاف تشغيلهم هناك أو هنا.</string>
|
<string name="reminder_benefit_duplicates_body">إذا تطبيق آخر أيضًا ينشر تذكيرات، فستراهم مرتين — قم بإيقاف تشغيلهم هناك أو هنا.</string>
|
||||||
@@ -219,8 +220,8 @@
|
|||||||
<string name="settings_dynamic_color">اللون الديناميكي</string>
|
<string name="settings_dynamic_color">اللون الديناميكي</string>
|
||||||
<string name="settings_dynamic_color_unavailable">يتطلب أندرويد ١٢ أو أحدث</string>
|
<string name="settings_dynamic_color_unavailable">يتطلب أندرويد ١٢ أو أحدث</string>
|
||||||
<string name="settings_default_view">طريقة العرض الافتراضية</string>
|
<string name="settings_default_view">طريقة العرض الافتراضية</string>
|
||||||
<string name="settings_soften_colors">ألوان تقويم ناعمة</string>
|
<string name="settings_soften_colors">ألوان تقويم متناسقة</string>
|
||||||
<string name="settings_soften_colors_summary">خفف ألوان التقويم والأحداث لتتناسب مع الثيم. قم بإيقافه لإظهار الألوان الخام من مصدر التقويم.</string>
|
<string name="settings_soften_colors_summary">حافظ على درجة لون كل تقويم لكن قم بتسوية سطوعه، بحيث كل حدث يظل مقروءًا و تتناسق الألوان معًا. قم بإيقافه لإظهار الألوان الخام من مصدر التقويم.</string>
|
||||||
<string name="settings_font_headings">خط العناوين</string>
|
<string name="settings_font_headings">خط العناوين</string>
|
||||||
<string name="settings_font_body">خط النص</string>
|
<string name="settings_font_body">خط النص</string>
|
||||||
<string name="settings_font_system">افتراضي النظام</string>
|
<string name="settings_font_system">افتراضي النظام</string>
|
||||||
@@ -242,17 +243,17 @@
|
|||||||
<string name="agenda_range_this_week">هذا الأسبوع</string>
|
<string name="agenda_range_this_week">هذا الأسبوع</string>
|
||||||
<string name="settings_past_events_show">إظهار</string>
|
<string name="settings_past_events_show">إظهار</string>
|
||||||
<string name="settings_past_events_hide">إخفاء</string>
|
<string name="settings_past_events_hide">إخفاء</string>
|
||||||
<string name="settings_agenda_header">جدول</string>
|
<string name="settings_agenda_header">الجدول</string>
|
||||||
<string name="event_edit_timezone_device">المنطقة الزمنية للجهاز</string>
|
<string name="event_edit_timezone_device">المنطقة الزمنية للجهاز</string>
|
||||||
<string name="event_edit_timezone_search">البحث عن المناطق الزمنية</string>
|
<string name="event_edit_timezone_search">البحث عن المناطق الزمنية</string>
|
||||||
<string name="event_edit_timezone_all">جميع المناطق الزمنية</string>
|
<string name="event_edit_timezone_all">جميع المناطق الزمنية</string>
|
||||||
<string name="event_edit_timezone_none">لا منطقة زمنية تطابق \"%1$s\"</string>
|
<string name="event_edit_timezone_none">لا منطقة زمنية تطابق ”%1$s“</string>
|
||||||
<string name="event_edit_timezone_local_time">%1$s توقيتك</string>
|
<string name="event_edit_timezone_local_time">%1$s توقيتك</string>
|
||||||
<string name="event_edit_timezone_recent">الأحدث</string>
|
<string name="event_edit_timezone_recent">الأحدث</string>
|
||||||
<string name="event_edit_recurrence_incomplete">أدخل رقمًا من ١ إلى ٩٩٩</string>
|
<string name="event_edit_recurrence_incomplete">أدخل رقمًا من ١ إلى ٩٩٩</string>
|
||||||
<plurals name="duration_minutes">
|
<plurals name="duration_minutes">
|
||||||
<item quantity="zero">(%d) لا دقائق</item>
|
<item quantity="zero">(%d) لا دقائق</item>
|
||||||
<item quantity="one">(%d) دقيقة واحده</item>
|
<item quantity="one">%d دقيقة واحده</item>
|
||||||
<item quantity="two">%d دقيقتان</item>
|
<item quantity="two">%d دقيقتان</item>
|
||||||
<item quantity="few">%d دقائق</item>
|
<item quantity="few">%d دقائق</item>
|
||||||
<item quantity="many">%d دقيقة</item>
|
<item quantity="many">%d دقيقة</item>
|
||||||
@@ -260,7 +261,7 @@
|
|||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="duration_hours">
|
<plurals name="duration_hours">
|
||||||
<item quantity="zero">(%d) لا ساعات</item>
|
<item quantity="zero">(%d) لا ساعات</item>
|
||||||
<item quantity="one">(%d) ساعة واحده</item>
|
<item quantity="one">%d ساعة واحده</item>
|
||||||
<item quantity="two">%d ساعتان</item>
|
<item quantity="two">%d ساعتان</item>
|
||||||
<item quantity="few">%d ساعات</item>
|
<item quantity="few">%d ساعات</item>
|
||||||
<item quantity="many">%d ساعة</item>
|
<item quantity="many">%d ساعة</item>
|
||||||
@@ -268,7 +269,7 @@
|
|||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="duration_days">
|
<plurals name="duration_days">
|
||||||
<item quantity="zero">(%d) لا أيام</item>
|
<item quantity="zero">(%d) لا أيام</item>
|
||||||
<item quantity="one">(%d) يوم واحد</item>
|
<item quantity="one">%d يوم واحد</item>
|
||||||
<item quantity="two">%d يومان</item>
|
<item quantity="two">%d يومان</item>
|
||||||
<item quantity="few">%d أيام</item>
|
<item quantity="few">%d أيام</item>
|
||||||
<item quantity="many">%d يوم</item>
|
<item quantity="many">%d يوم</item>
|
||||||
@@ -276,7 +277,7 @@
|
|||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="duration_weeks">
|
<plurals name="duration_weeks">
|
||||||
<item quantity="zero">(%d) لا أسابيع</item>
|
<item quantity="zero">(%d) لا أسابيع</item>
|
||||||
<item quantity="one">(%d) أسبوع واحد</item>
|
<item quantity="one">%d أسبوع واحد</item>
|
||||||
<item quantity="two">%d أسبوعان</item>
|
<item quantity="two">%d أسبوعان</item>
|
||||||
<item quantity="few">%d أسابيع</item>
|
<item quantity="few">%d أسابيع</item>
|
||||||
<item quantity="many">%d أسبوع</item>
|
<item quantity="many">%d أسبوع</item>
|
||||||
@@ -291,10 +292,390 @@
|
|||||||
<string name="settings_dim_completed_summary">تعتيم الأحداث التي انتهت بالفعل في عرض الشهر والأسبوع</string>
|
<string name="settings_dim_completed_summary">تعتيم الأحداث التي انتهت بالفعل في عرض الشهر والأسبوع</string>
|
||||||
<string name="settings_today_toolbar">زر اليوم في شريط الأدوات</string>
|
<string name="settings_today_toolbar">زر اليوم في شريط الأدوات</string>
|
||||||
<string name="settings_today_toolbar_summary">إظهار زر الانتقال إلى اليوم في شريط الأدوات بدلاً من زر عائم</string>
|
<string name="settings_today_toolbar_summary">إظهار زر الانتقال إلى اليوم في شريط الأدوات بدلاً من زر عائم</string>
|
||||||
<string name="settings_app_name_summary">اعرض Calendula كـ \"Calendar\" في مشغّل التطبيقات الخاص بك. فقط الاسم في المشغّل يتغير؛ وقد ينتقل الرمز إلى مكان جديد بعد التبديل.</string>
|
<string name="settings_app_name_summary">اعرض Calendula كـ ”Calendar“ في مشغّل التطبيقات الخاص بك. فقط الاسم في المشغّل يتغير؛ وقد ينتقل الرمز إلى مكان جديد بعد التبديل.</string>
|
||||||
<string name="settings_past_events_dim">تعتيم</string>
|
<string name="settings_past_events_dim">تعتيم</string>
|
||||||
<string name="settings_past_events">الأحداث السابقة</string>
|
<string name="settings_past_events">الأحداث السابقة</string>
|
||||||
<string name="settings_agenda_range">مدى الجدول</string>
|
<string name="settings_agenda_range">مدى الجدول</string>
|
||||||
<string name="agenda_range_custom">مخصص…</string>
|
<string name="agenda_range_custom">مخصص…</string>
|
||||||
<string name="agenda_range_custom_hint">أيام</string>
|
<string name="agenda_range_custom_hint">أيام</string>
|
||||||
|
<string name="agenda_range_week">٧ أيام القادمة</string>
|
||||||
|
<string name="agenda_range_month">٣٠ يومًا القادمة</string>
|
||||||
|
<string name="settings_month_view_style">نمط عرض الشهر</string>
|
||||||
|
<string name="month_style_paged">صفحات</string>
|
||||||
|
<string name="month_style_paged_summary">الشهر الواحد يملأ الشاشة. اسحب لليسار أو لليمين لتغيير الشهر.</string>
|
||||||
|
<string name="month_style_split">تقسيم</string>
|
||||||
|
<string name="settings_color_unsupported">السماح بالألوان في التقويمات غير المدعومة</string>
|
||||||
|
<string name="settings_color_unsupported_hint">بعض التقويمات (مثل البعض من CalDAV) لا تنشر أي مجموعة ألوان؛ لون حدث مخصص قد يزال أو يستبدل في مزامنته التالية. هذا قيد على تلك التقويمات، وليس شيئًا يمكن لـ Calendula إصلاحه.</string>
|
||||||
|
<string name="settings_section_views">طُرق العرض</string>
|
||||||
|
<string name="agenda_range_showing_label">إظهار جميع الأحداث القادمة لـ</string>
|
||||||
|
<string name="settings_reminders">تذكيرات الحدث</string>
|
||||||
|
<string name="settings_default_reminder">التذكير الافتراضي</string>
|
||||||
|
<string name="reminder_custom_set">تعيين</string>
|
||||||
|
<string name="settings_autofocus_title_hint">عند بدء حدث جديد، ضع المؤشر في حقل العنوان وافتح لوحة المفاتيح على الفور.</string>
|
||||||
|
<string name="settings_form_fields_hint">الخانات المعروضة افتراضيًا — كل شيء آخر موجود ضمن \"المزيد من الخانات\"</string>
|
||||||
|
<string name="settings_section_event_form">نموذج حدث جديد</string>
|
||||||
|
<string name="settings_quick_switch_header">زر التبديل السريع</string>
|
||||||
|
<string name="settings_theme_system_summary">الحالي %1$s</string>
|
||||||
|
<string name="settings_theme_hint">سواء التطبيق فاتحًا أو داكنًا. الاختيار يُطبق على الفور.</string>
|
||||||
|
<string name="settings_week_start_auto_summary">الحالي %1$s</string>
|
||||||
|
<string name="settings_time_format_auto_summary">اتباع النظام: %1$s</string>
|
||||||
|
<string name="settings_hour_lines">خطوط الساعات</string>
|
||||||
|
<string name="settings_hour_lines_summary">إظهار خط فاصل في كل ساعة في طريقة عرض الأسبوع واليوم</string>
|
||||||
|
<string name="timeline_scale_fit_day_summary">جميع الـ 24 ساعة على شاشة واحدة، لا تمرير</string>
|
||||||
|
<string name="timeline_scale_custom">مخصص</string>
|
||||||
|
<string name="settings_widget_size_small">صغير</string>
|
||||||
|
<string name="settings_widget_size_medium">متوسط</string>
|
||||||
|
<string name="settings_widget_size_large">كبير</string>
|
||||||
|
<string name="settings_widget_size_extra_large">كبير جدًا</string>
|
||||||
|
<plurals name="agenda_range_days">
|
||||||
|
<item quantity="zero">(%d) لا أيام</item>
|
||||||
|
<item quantity="one">%d يوم واحد</item>
|
||||||
|
<item quantity="two">%d يومان</item>
|
||||||
|
<item quantity="few">%d أيام</item>
|
||||||
|
<item quantity="many">%d يوم</item>
|
||||||
|
<item quantity="other">%d يوم</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="settings_agenda_range_bar_hint">اعرض شريطًا في الجزء العلوي من الجدول يحدد التواريخ المعروضة، مع زر لتبديل النطاق للجلسة</string>
|
||||||
|
<string name="settings_agenda_range_bar">شريط النطاق</string>
|
||||||
|
<string name="settings_month_header">عرض الشهر</string>
|
||||||
|
<string name="month_style_continuous">تمرير الشهور</string>
|
||||||
|
<string name="month_style_dense">أسابيع سلسة</string>
|
||||||
|
<string name="month_style_continuous_summary">كل شهر يقع تحت عنوانه الخاص، مع مساحة صغيرة تميزه عن الشهر التالي.</string>
|
||||||
|
<string name="month_style_dense_summary">تمر الأسابيع دون انقطاع، ويتدفق كل شهر مباشرة إلى الشهر التالي دون وجود فجوة بينهما.</string>
|
||||||
|
<string name="month_split_no_events">لا شيء مجدول</string>
|
||||||
|
<string name="month_style_split_summary">شبكة مدمجة تحدد الأيام التي تتضمن أحداثًا، ويتم إدراج اليوم الذي تنقر عليه تحتها.</string>
|
||||||
|
<string name="settings_autofocus_title">تركيز العنوان على حدث جديد</string>
|
||||||
|
<string name="settings_event_duration">المدة الافتراضية</string>
|
||||||
|
<string name="settings_event_duration_hint">مدة الحدث الجديد تبقى كما هي حتى أنت تغيير وقت نهايته. أما أحداث التي هي طوال اليوم فلن تتأثر.</string>
|
||||||
|
<string name="settings_section_notifications">الإشعارات</string>
|
||||||
|
<string name="settings_reminders_hint">ترى التذكيرات مرتين؟ هناك تطبيق تقويم آخر ينشرهم أيضًا — قم بإيقافهم في أحد من الاثنين.</string>
|
||||||
|
<string name="settings_group_look">المظهر و السلوك</string>
|
||||||
|
<string name="settings_group_data">البيانات</string>
|
||||||
|
<string name="settings_group_app">التطبيق</string>
|
||||||
|
<string name="settings_language">لغة التطبيق</string>
|
||||||
|
<string name="settings_dynamic_color_summary">أخذ ألوان التطبيق من خلفيتك.</string>
|
||||||
|
<string name="settings_group_about">حول</string>
|
||||||
|
<string name="settings_about_author">بواسطة Jean-Luc Makiola</string>
|
||||||
|
<string name="settings_about_source">المصدر</string>
|
||||||
|
<string name="settings_about_privacy">سياسة الخصوصية</string>
|
||||||
|
<string name="settings_about_support">دَعم التطوير</string>
|
||||||
|
<string name="settings_about_version">الإصدار %1$s</string>
|
||||||
|
<string name="settings_about_logo_desc">رمز تطبيق Calendula</string>
|
||||||
|
<string name="crash_report_body_template">شكرًا لإبلاغ عن عطل في %1$s. يُرجى إضافة أي شيء تتذكره حول ما كنت تفعله، ثم إرسال.\n\n### ماذا حدث\n\n\n### تقرير العطل\n%2$s\n</string>
|
||||||
|
<string name="settings_section_about">حول</string>
|
||||||
|
<string name="settings_report_problem">الإبلاغ عن مشكلة</string>
|
||||||
|
<string name="settings_report_problem_hint">أرسل تقرير الأعطال أو افتح متعقب المشكلات</string>
|
||||||
|
<string name="settings_language_auto">افتراضي النظام</string>
|
||||||
|
<string name="settings_section_backup">النسخ الاحتياطي والاستعادة</string>
|
||||||
|
<string name="settings_special_dates_enable">إظهار تواريخ جهات الاتصال</string>
|
||||||
|
<string name="settings_special_dates_enable_hint">اعكس أعياد ميلاد جهات اتصالك والتواريخ الأخرى إلى التقويمات المحلية. يقرأ جهات الاتصال على هذا الجهاز فقط — لا يتم رفع أي شئ، وجهات اتصالك لا يتم تغييرها أبدًا.</string>
|
||||||
|
<string name="settings_section_language">اللغة</string>
|
||||||
|
<string name="qs_tile_new_event_label">حدث جديد</string>
|
||||||
|
<string name="shortcut_new_event_long">إنشاء حدث جديد</string>
|
||||||
|
<string name="shortcut_new_event_short">حدث جديد</string>
|
||||||
|
<string name="settings_qs_tile_hint">أضف مربع ”حدث جديد“ إلى لوحة الإعدادات السريعة.</string>
|
||||||
|
<string name="calendars_local_header">تقويماتك</string>
|
||||||
|
<string name="settings_section_special_dates">تواريخ جهات الاتصال الخاصة</string>
|
||||||
|
<string name="calendars_title">التقويمات</string>
|
||||||
|
<string name="calendars_add">إضافة تقويم</string>
|
||||||
|
<string name="settings_section_calendars">التقويمات</string>
|
||||||
|
<string name="settings_manage_calendars">إدارة التقويمات</string>
|
||||||
|
<string name="event_edit_recurrence_next">التالي: %1$s</string>
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="zero">(%d) لا دقائق قبل</item>
|
||||||
|
<item quantity="one">%d دقيقة واحده قبل</item>
|
||||||
|
<item quantity="two">%d دقيقتان قبل</item>
|
||||||
|
<item quantity="few">%d دقائق قبل</item>
|
||||||
|
<item quantity="many">%d دقيقة قبل</item>
|
||||||
|
<item quantity="other">%d دقيقة قبل</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="zero">(%d) لا ساعات قبل</item>
|
||||||
|
<item quantity="one">%d ساعة واحده قبل</item>
|
||||||
|
<item quantity="two">%d ساعتان قبل</item>
|
||||||
|
<item quantity="few">%d ساعات قبل</item>
|
||||||
|
<item quantity="many">%d ساعة قبل</item>
|
||||||
|
<item quantity="other">%d ساعة قبل</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="zero">(%d) لا أيام قبل</item>
|
||||||
|
<item quantity="one">%d يوم واحد قبل</item>
|
||||||
|
<item quantity="two">%d يومان قبل</item>
|
||||||
|
<item quantity="few">%d أيام قبل</item>
|
||||||
|
<item quantity="many">%d يوم قبل</item>
|
||||||
|
<item quantity="other">%d يوم قبل</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="zero">(%d) لا أسابيع قبل</item>
|
||||||
|
<item quantity="one">%d أسبوع واحد قبل</item>
|
||||||
|
<item quantity="two">%d أسبوعان قبل</item>
|
||||||
|
<item quantity="few">%d أسابيع قبل</item>
|
||||||
|
<item quantity="many">%d أسبوع قبل</item>
|
||||||
|
<item quantity="other">%d أسبوع قبل</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="reminder_benefit_reversible_body">يوجد مفتاح التبديل في الإعدادات، ضمن الإشعارات.</string>
|
||||||
|
<string name="reminder_custom_amount">القيمة</string>
|
||||||
|
<string name="settings_manage_calendars_hint">إنشاء تقويمات محلية؛ إدارة التقويمات المتزامنة</string>
|
||||||
|
<string name="settings_snooze_duration">مدة التأجيل</string>
|
||||||
|
<string name="settings_calendar_reminder_inherits">الافتراضي (%1$s)</string>
|
||||||
|
<string name="settings_translate">المساعدة في الترجمة</string>
|
||||||
|
<string name="settings_translate_hint">إضافة أو تحسين لغة على Weblate</string>
|
||||||
|
<string name="settings_appearance_subtitle">الثيم، الألوان، الخطوط</string>
|
||||||
|
<string name="settings_views_subtitle">العرض، التخطيط، الترتيب</string>
|
||||||
|
<string name="settings_event_form_subtitle">الخانات الافتراضية و السلوك</string>
|
||||||
|
<string name="settings_notifications_subtitle">التذكيرات و التسليم</string>
|
||||||
|
<string name="settings_special_dates_subtitle">أعياد ميلاد جهات الاتصال و احتفالات ذكرى</string>
|
||||||
|
<string name="settings_special_dates_type_birthday">أعياد الميلاد</string>
|
||||||
|
<string name="settings_special_dates_type_custom">تواريخ أخرى</string>
|
||||||
|
<string name="settings_license">الترخيص</string>
|
||||||
|
<string name="settings_special_dates_disable_title">إيقاف تشغيل تواريخ جهات الاتصال؟</string>
|
||||||
|
<string name="settings_special_dates_disable_confirm">إيقاف</string>
|
||||||
|
<string name="settings_special_dates_sync_now">زامن الآن</string>
|
||||||
|
<string name="settings_special_dates_never_synced">لم تتم المزامنة بعد</string>
|
||||||
|
<string name="settings_special_dates_paused_hint">لم يعد بإمكان Calendula قراءة جهات الاتصال الخاصة بك، لذلك لا يتم تحديث هذه التقويمات.</string>
|
||||||
|
<string name="calendars_manage_in_app">الإدارة في التطبيق</string>
|
||||||
|
<string name="calendars_enable_all">تفعيل الكل</string>
|
||||||
|
<string name="calendars_disable_all">تعطيل الكل</string>
|
||||||
|
<string name="calendars_add_account">إضافة حساب</string>
|
||||||
|
<string name="calendars_new_title">تقويم جديد</string>
|
||||||
|
<string name="calendars_edit_title">تعديل التقويم</string>
|
||||||
|
<string name="calendars_name_label">الاسم</string>
|
||||||
|
<string name="calendars_color_label">اللون</string>
|
||||||
|
<string name="calendars_description_hint">أضف وصفًا</string>
|
||||||
|
<string name="calendars_delete_confirm_title">حذف التقويم؟</string>
|
||||||
|
<string name="calendars_delete_confirm_message">\"%1$s\" وجميع أحداثه ستتم إزالتها نهائيًا من هذا الجهاز.</string>
|
||||||
|
<string name="calendars_write_error">تعذّر حفظ التغيير.</string>
|
||||||
|
<string name="calendars_backup_header">النسخ الاحتياطي</string>
|
||||||
|
<string name="calendars_backup_hint">التقويمات المحلية لا تتم مزامنتها في أي مكان، لذا قم بتصديرها إلى ملف .ics للاحتفاظ بنسخة.</string>
|
||||||
|
<string name="dialog_save">حفظ</string>
|
||||||
|
<string name="settings_special_dates_grant">منح الوصول</string>
|
||||||
|
<string name="calendars_backup_action">تصدير كملف .ics</string>
|
||||||
|
<string name="calendars_export_title">تصدير التقويمات</string>
|
||||||
|
<string name="calendars_export_hint">اختر التقويمات التي تريد تضمينها في ملف .ics.</string>
|
||||||
|
<string name="calendars_export_action">تصدير</string>
|
||||||
|
<string name="calendars_restore_header">استعادة</string>
|
||||||
|
<string name="calendars_restore_action">استعادة من ملف .ics</string>
|
||||||
|
<string name="calendars_restore_hint">استيراد الأحداث من نسخة احتياطية أو تطبيق تقويم آخر.</string>
|
||||||
|
<string name="calendars_auto_backup">النسخ الاحتياطي التلقائي</string>
|
||||||
|
<string name="calendars_auto_backup_hint">قم بتصدير تقويماتك المحلية بشكل دوري إلى مجلد كـ ملف .ics.</string>
|
||||||
|
<string name="calendars_auto_backup_folder">مجلد النسخ الاحتياطي</string>
|
||||||
|
<string name="calendars_auto_backup_folder_unset">اضغط لاختيار مجلد</string>
|
||||||
|
<string name="calendars_auto_backup_every">كل %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_interval_min">الحد الأدني ٣٠ دقيقة.</string>
|
||||||
|
<string name="calendars_auto_backup_status_never">لا نسخ احتياطي تلقائي بعد</string>
|
||||||
|
<string name="calendars_auto_backup_status_ok">آخر نسخة احتياطية: %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_status_failed">فشل آخر نسخ احتياطي: %1$s</string>
|
||||||
|
<string name="backup_channel_name">النسخ الاحتياطي</string>
|
||||||
|
<string name="backup_channel_description">يحذّر إذا تفشل النسخ الاحتياطي التلقائي بشكل متكرر.</string>
|
||||||
|
<string name="backup_failed_title">فشل النسخ الاحتياطي التلقائي</string>
|
||||||
|
<string name="backup_failed_text">Calendula تعذَّر في كتابة ملف النسخ الاحتياطي. تحقق من مجلد النسخ الاحتياطي في الإعدادات.</string>
|
||||||
|
<string name="calendars_backup_failed">تعذّر تصدير النسخة الاحتياطية.</string>
|
||||||
|
<plurals name="calendars_backup_done">
|
||||||
|
<item quantity="zero">تم تصدير (%d) لا أحداث.</item>
|
||||||
|
<item quantity="one">تم تصدير %d حدث واحد.</item>
|
||||||
|
<item quantity="two">تم تصدير %d حدثان.</item>
|
||||||
|
<item quantity="few">تم تصدير %d أحداث.</item>
|
||||||
|
<item quantity="many">تم تصدير %d حدث.</item>
|
||||||
|
<item quantity="other">تم تصدير %d حدث.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="import_title">استيراد الأحداث</string>
|
||||||
|
<string name="import_target_header">إضافة إلى تقويم</string>
|
||||||
|
<string name="import_empty">لم يتم العثور على أحداث في هذا الملف.</string>
|
||||||
|
<string name="import_failed">تعذَّر قراءة هذا الملف.</string>
|
||||||
|
<string name="import_no_calendar">لا تقويم قابل للكتابة لاستيراد إليه. أنشئ تقويمًا محليًا أولاً.</string>
|
||||||
|
<string name="import_done_title">اكتمل الاستيراد</string>
|
||||||
|
<string name="import_done_dedup_note">تم تخطي الأحداث الموجودة بالفعل في التقويم.</string>
|
||||||
|
<string name="settings_special_dates_reminders">التذكيرات</string>
|
||||||
|
<string name="calendars_account_menu_a11y">المزيد من الخيارات لـ %1$s</string>
|
||||||
|
<string name="calendars_synced_hint">تأتي هذه من الحسابات الموجودة على جهازك. يمكنك إنشاؤها وتعديلها في تطبيقها الخاص.</string>
|
||||||
|
<string name="calendars_synced_header">التقويمات المتزامنة</string>
|
||||||
|
<string name="calendars_local_empty">لا تقويمات محلية حتى الآن. أنشئ واحدًا للاحتفاظ بالأحداث على هذا الجهاز فقط.</string>
|
||||||
|
<string name="calendars_auto_backup_interval">الفاصل الزمني</string>
|
||||||
|
<string name="import_warning_no_start">تم تخطي حدث بدون وقت بدء.</string>
|
||||||
|
<string name="import_warning_recurrence">تم تخطي بعض التكرارات التي تم تغييرها للأحداث المتكررة.</string>
|
||||||
|
<string name="import_close">إغلاق</string>
|
||||||
|
<string name="import_done_skipped_label">التكرارات</string>
|
||||||
|
<string name="import_done_added_label">أُضيف</string>
|
||||||
|
<string name="crash_dialog_title">%1$s تعطل</string>
|
||||||
|
<string name="settings_qs_tile">إضافة مربع الإعدادات السريعة</string>
|
||||||
|
<string name="import_button">استيراد</string>
|
||||||
|
<string name="crash_dialog_dismiss">ليس الآن</string>
|
||||||
|
<string name="crash_report_copied">تم نسخ التقرير إلى الحافظة</string>
|
||||||
|
<string name="crash_report_open_failed">تعذّر فتح متتبع المشكلات. التقرير موجود في الحافظة الخاصة بك.</string>
|
||||||
|
<string name="crash_report_issue_title">تقرير العطل</string>
|
||||||
|
<string name="crash_report_clip_label">%1$s تقرير العطل</string>
|
||||||
|
<string name="crash_report_body_paste">_(كان التقرير طويلًا جدًا لهذا الرابط — الصقه من الحافظة هنا.)_</string>
|
||||||
|
<string name="special_dates_calendar_birthday">أعياد الميلاد</string>
|
||||||
|
<string name="special_dates_calendar_custom">التواريخ الخاصة</string>
|
||||||
|
<string name="event_edit_recurrence_next_none">هذه القاعدة لا تتكرر أبدًا</string>
|
||||||
|
<string name="event_access_public_summary">يري كل شخص لديه حق الوصول التفاصيل الكاملة</string>
|
||||||
|
<string name="event_access_private_summary">الآخرون يرون فقط أنك مشغول</string>
|
||||||
|
<string name="event_access_default_summary">أينما كان ما يفعله هذا التقويم عادةً</string>
|
||||||
|
<string name="month_split_collapse">عرض أحداث اليوم</string>
|
||||||
|
<string name="month_split_expand">إظهار الشهر بأكمله</string>
|
||||||
|
<string name="settings_week_day_header">الأسبوع و اليوم</string>
|
||||||
|
<string name="settings_default_view_hint">طريقة العرض التي يفتحها Calendula عندما تقوم ببدءه.</string>
|
||||||
|
<string name="settings_week_start_hint">اليوم الذي يبدأ به كل أسبوع، في جميع طرق العرض والودجتز.</string>
|
||||||
|
<string name="settings_time_format_hint">كيف الأوقات تُكتب في جميع أنحاء التطبيق. تلقائيًا يتبع إعداد نظامك.</string>
|
||||||
|
<string name="settings_past_events_hint">ما الذي يفعله جدول بالأحداث التي انتهت بالفعل.</string>
|
||||||
|
<string name="calendars_visibility_a11y">إظهار \"%1$s\"</string>
|
||||||
|
<string name="calendars_visibility_notice_title">بعض التقويمات متوقفة</string>
|
||||||
|
<string name="calendars_visibility_notice_message">Calendula يعرض التقويمات التي تم تشغيلها لهذا الجهاز، وبعض من خاصتك متوقفة. لرؤية أحداثهم، قم بتشغيلهم ضمن الإعدادات ← التقويمات.</string>
|
||||||
|
<string name="calendar_picker_missing_title">تفتقد تقويمًا؟</string>
|
||||||
|
<string name="calendar_picker_missing_summary">قد يكون متوقفًا عن التشغيل، للقراءة فقط أو مملوءًا من جهات الاتصال الخاصة بك — يمكنك إدارة تقويماتك هنا.</string>
|
||||||
|
<string name="calendars_state_read_only">للقراءة فقط</string>
|
||||||
|
<string name="calendars_state_not_synced">لم تتم المزامنة مع هذا الجهاز</string>
|
||||||
|
<string name="calendars_state_managed">تم ملؤه من جهات اتصالك</string>
|
||||||
|
<string name="calendars_managed_delete_locked">تم ملء هذا التقويم من جهات الاتصال الخاصة بك، لذلك سيقوم Calendula بإنشائه مرة أخرى في المزامنة التالية. قم بإيقاف تشغيل التواريخ الخاصة ضمن الإعدادات ← التواريخ الخاصة لحذفها.</string>
|
||||||
|
<string name="duration_custom_max">كحد أقصى %1$s</string>
|
||||||
|
<string name="settings_calendar_duration_inherits">الافتراضي (%1$s)</string>
|
||||||
|
<string name="settings_calendar_duration_use_default">استخدام المدة الافتراضية (%1$s)</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">التذكيرات، تُسلَّم</string>
|
||||||
|
<string name="reminder_use_default">استخدام التذكير الافتراضي</string>
|
||||||
|
<string name="settings_timeline_scale">ارتفاع الساعة</string>
|
||||||
|
<string name="settings_timeline_scale_hint">كم مدى المساحة العمودية التي تأخذها الساعة الواحده في عرض الأسبوع واليوم. كلا العرضين يشتركا في هذا الإعداد. يمكنك أيضًا الضغط على المخطط الزمني بإصبعين لتعيين أي ارتفاع بين.</string>
|
||||||
|
<string name="timeline_scale_fit_day">الملائمة لليوم بأكمله</string>
|
||||||
|
<string name="timeline_scale_compact">مضغوط</string>
|
||||||
|
<string name="timeline_scale_regular_summary">المساحة القياسية</string>
|
||||||
|
<string name="timeline_scale_comfortable">مُريَّح</string>
|
||||||
|
<string name="timeline_scale_compact_summary">المزيد من الساعات لكل شاشة، ومجموعات أصغر</string>
|
||||||
|
<string name="timeline_scale_regular">العادي</string>
|
||||||
|
<string name="timeline_scale_comfortable_summary">مجموعات أوسع، تمرير أكثر</string>
|
||||||
|
<string name="timeline_scale_custom_summary">الارتفاع الذي قمت بضغظت المخطط الزمني إليه</string>
|
||||||
|
<string name="event_edit_timezone_device_summary">يتابعك أينما كنت</string>
|
||||||
|
<string name="event_access_confidential_summary">مُعَلَّم كـ سري؛ ما يعنيه ذلك متروك لحساب التقويم</string>
|
||||||
|
<string name="settings_widget_size_summary">نص الحدث %1$d%%</string>
|
||||||
|
<string name="settings_calendar_durations_hint">امنح تقويم طوله الافتراضي خاص به — للمثال. ٨ ساعات لمناوبات العمل.</string>
|
||||||
|
<string name="settings_backup_subtitle">تصدير، استيراد، النسخ الاحتياطي التلقائي</string>
|
||||||
|
<string name="settings_views_all_header">كل طُرق العرض</string>
|
||||||
|
<string name="settings_calendar_durations_title">المدة لكل تقويم</string>
|
||||||
|
<string name="event_move_done">تم النقل إلى %1$s</string>
|
||||||
|
<string name="event_move_undo">تراجع</string>
|
||||||
|
<string name="event_move_undone">تم التراجع عن النقل</string>
|
||||||
|
<string name="event_move_failed">تعذّر نقل الحدث</string>
|
||||||
|
<string name="event_move_write_denied">Calendula يحتاج إلى صلاحية للكتابة لنقل الأحداث</string>
|
||||||
|
<string name="event_move_gone">لم يعد هذا الحدث موجودًا</string>
|
||||||
|
<string name="onboarding_step_counter">الخطوة %1$d من %2$d</string>
|
||||||
|
<string name="onboarding_backup_title">أحداثك تعيش فقط هنا</string>
|
||||||
|
<string name="onboarding_backup_body">لا شئ على هذا الهاتف تتم مزامنته لحساب، لذلك فقدانها سيؤدي لفقدان تقويمك. Calendula يمكنه كتابة نسخة احتياطية لك.</string>
|
||||||
|
<string name="onboarding_backup_benefit_folder_body">النسخ الاحتياطية هي ملفات .ics عادية — ضعها في مكان ما يزامن، أو على بطاقة SD.</string>
|
||||||
|
<string name="onboarding_backup_benefit_daily_title">مرة واحدة يوميًا، من تلقاء نفسه</string>
|
||||||
|
<string name="onboarding_backup_benefit_daily_body">Calendula يقم بتصدير تقويماتك المحلية في الخلفية. يمكنك تغيير مدى المرات في الإعدادات.</string>
|
||||||
|
<string name="onboarding_backup_enable_button">اختر مجلد وانسخ احتياطيًا</string>
|
||||||
|
<string name="onboarding_backup_skip_button">ليس الآن</string>
|
||||||
|
<string name="onboarding_view_title">ما الذي يجب أن يفتح أولاً؟</string>
|
||||||
|
<string name="onboarding_month_style_title">كيف يجب أن يبدو الشهر؟</string>
|
||||||
|
<string name="onboarding_view_continue_button">متابعة</string>
|
||||||
|
<string name="onboarding_back">رجوع</string>
|
||||||
|
<string name="onboarding_visibility_button">فهمت</string>
|
||||||
|
<string name="onboarding_done_title">تم إعدادك بالكامل</string>
|
||||||
|
<string name="onboarding_done_body">تقويماتك جاهزه. يمكن تغيير كل ما اخترته للتو لاحقًا في الإعدادات.</string>
|
||||||
|
<string name="onboarding_done_button">افتح تقويمي</string>
|
||||||
|
<plurals name="search_selected_count">
|
||||||
|
<item quantity="zero">تم تحديد (%d)</item>
|
||||||
|
<item quantity="one">تم تحديد %d</item>
|
||||||
|
<item quantity="two">تم تحديد %d</item>
|
||||||
|
<item quantity="few">تم تحديد %d</item>
|
||||||
|
<item quantity="many">تم تحديد %d</item>
|
||||||
|
<item quantity="other">تم تحديد %d</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="search_in_descriptions">عُثِر في الأوصاف</string>
|
||||||
|
<string name="search_selection_close">إلغاء التحديد</string>
|
||||||
|
<string name="search_select_all">تحديد الكل</string>
|
||||||
|
<string name="search_delete_selected">حذف المُحدّد</string>
|
||||||
|
<plurals name="search_delete_title">
|
||||||
|
<item quantity="zero">حذف (%d) حدث؟</item>
|
||||||
|
<item quantity="one">حذف %d حدث واحد؟</item>
|
||||||
|
<item quantity="two">حذف %d حدثان؟</item>
|
||||||
|
<item quantity="few">حذف %d أحداث؟</item>
|
||||||
|
<item quantity="many">حذف %d حدث؟</item>
|
||||||
|
<item quantity="other">حذف %d حدث؟</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="search_delete_done">
|
||||||
|
<item quantity="zero">تم حذف (%d) لا حدث</item>
|
||||||
|
<item quantity="one">تم حذف %d حدث واحد</item>
|
||||||
|
<item quantity="two">تم حذف %d حدثان</item>
|
||||||
|
<item quantity="few">تم حذف %d أحداث</item>
|
||||||
|
<item quantity="many">تم حذف %d حدث</item>
|
||||||
|
<item quantity="other">تم حذف %d حدث</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="search_delete_partial">تم حذف %1$d، وتعذّر حذف %2$d</string>
|
||||||
|
<string name="search_delete_denied_partial">تم حذف %1$d، ثم تم سحب صلاحية للكتابة</string>
|
||||||
|
<plurals name="import_done_skipped">
|
||||||
|
<item quantity="zero">تم تخطي (%d) بالفعل في هذا التقويم.</item>
|
||||||
|
<item quantity="one">تم تخطي %d بالفعل في هذا التقويم.</item>
|
||||||
|
<item quantity="two">تم تخطي %d بالفعل في هذا التقويم.</item>
|
||||||
|
<item quantity="few">تم تخطي %d بالفعل في هذا التقويم.</item>
|
||||||
|
<item quantity="many">تم تخطي %d بالفعل في هذا التقويم.</item>
|
||||||
|
<item quantity="other">تم تخطي %d بالفعل في هذا التقويم.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_imported">
|
||||||
|
<item quantity="zero">تم استيراد (%d) لا أحداث.</item>
|
||||||
|
<item quantity="one">تم استيراد %d حدث واحد.</item>
|
||||||
|
<item quantity="two">تم استيراد %d حدثان.</item>
|
||||||
|
<item quantity="few">تم استيراد %d أحداث.</item>
|
||||||
|
<item quantity="many">تم استيراد %d حدث.</item>
|
||||||
|
<item quantity="other">تم استيراد %d حدث.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_action">
|
||||||
|
<item quantity="zero">استيراد (%d) لا أحداث</item>
|
||||||
|
<item quantity="one">استيراد %d حدث واحد</item>
|
||||||
|
<item quantity="two">استيراد %d حدثان</item>
|
||||||
|
<item quantity="few">استيراد %d أحداث</item>
|
||||||
|
<item quantity="many">استيراد %d حدث</item>
|
||||||
|
<item quantity="other">استيراد %d حدث</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_event_count">
|
||||||
|
<item quantity="zero">(%d) لا أحداث في هذا الملف.</item>
|
||||||
|
<item quantity="one">%d حدث واحد في هذا الملف.</item>
|
||||||
|
<item quantity="two">%d حدثان في هذا الملف.</item>
|
||||||
|
<item quantity="few">%d أحداث في هذا الملف.</item>
|
||||||
|
<item quantity="many">%d حدث في هذا الملف.</item>
|
||||||
|
<item quantity="other">%d حدث في هذا الملف.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_title_count">
|
||||||
|
<item quantity="zero">يتم استيراد (%d) لا أحداث</item>
|
||||||
|
<item quantity="one">يتم استيراد %d حدث واحد</item>
|
||||||
|
<item quantity="two">يتم استيراد %d حدثان</item>
|
||||||
|
<item quantity="few">يتم استيراد %d أحداث</item>
|
||||||
|
<item quantity="many">يتم استيراد %d حدث</item>
|
||||||
|
<item quantity="other">يتم استيراد %d حدث</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="event_move_recurring_title">نقل الحدث المتكرر</string>
|
||||||
|
<string name="event_edit_conflict_overwrite_hint">فقط الحقول التي عدلتها هي التي ستحل محل التغيير الخارجي</string>
|
||||||
|
<string name="reorder_drag_handle">اسحب لإعادة الترتيب</string>
|
||||||
|
<string name="settings_drawer_order_hint">اسحب لإعادة ترتيب طرق العرض المدرجة في قائمة التنقل.</string>
|
||||||
|
<string name="settings_drawer_order_header">قائمة التنقل</string>
|
||||||
|
<string name="settings_default_reminder_allday">أحداث يوم كامل</string>
|
||||||
|
<string name="settings_allday_reminder_time">وقت تذكير اليوم الكامل</string>
|
||||||
|
<string name="reminder_none">لا شئ</string>
|
||||||
|
<string name="settings_reliable_delivery_hint">أندرويد قد يؤخر التذكيرات لتوفير البطارية. استثنِ Calendula لكي تصل في الوقت المحدد.</string>
|
||||||
|
<string name="settings_calendar_reminders_title">التذكيرات لكل تقويم</string>
|
||||||
|
<string name="settings_license_value">MIT</string>
|
||||||
|
<string name="settings_special_dates_paused_title">متوقف</string>
|
||||||
|
<string name="settings_special_dates_calendar_hint">اضبط لون كل تقويم وإمكانية ظهوره في إعدادات التقويمات.</string>
|
||||||
|
<string name="calendars_visibility_hint">قم بإيقاف تقويم لإخفائه على هذا الجهاز — تختفي أحداثه من التطبيق ويتوقف عن تذكيرك. هذا هو مفتاح التبديل نفسه الذي تستخدمه تطبيقات التقويم الأخرى خاصتك، لذلك تقوم بإخفائه أيضًا. لا شيء يتم حذفه، ولا يتأثر أي جهاز آخر، ويمكنك إعادة تشغيله من هنا في أي وقت.</string>
|
||||||
|
<string name="settings_allday_reminder_fires_at">يتم الإشعار في %1$s</string>
|
||||||
|
<string name="settings_agenda_widget_range">نطاق ودجت الجدول</string>
|
||||||
|
<string name="settings_widget_size">حجم ودجت الجدول</string>
|
||||||
|
<string name="settings_widgets_subtitle">ودجت الجدول، عنصر الإعدادات السريعة</string>
|
||||||
|
<string name="settings_drag_to_reschedule">السحب لتعديل الموعد</string>
|
||||||
|
<string name="settings_drag_to_reschedule_summary">انقل حدثًا عن طريق سحبه إلى يوم أو وقت آخر</string>
|
||||||
|
<string name="event_move_blocked_series_end">لا يمكن نقل حدث بعد نهاية سلسلته</string>
|
||||||
|
<string name="crash_dialog_report">تقرير</string>
|
||||||
|
<string name="settings_quick_switch_hint">اختر أي طُرق العرض التي يتم التنقل بينها باستخدام الزر الموجود في أعلى اليمين، واسحب لإعادة ترتيبها. تظل طرق العرض التي تم إيقاف تشغيلها متاحة من قائمة التنقل.</string>
|
||||||
|
<string name="calendars_account_from_source">%1$s (%2$s)</string>
|
||||||
|
<string name="duration_hours_minutes">%1$s %2$s</string>
|
||||||
|
<string name="settings_widgets_hint">إعدادات لوجدتز الشاشة الرئيسية وعنصر لوحة الإعدادات السريعة. أضف وجدت عن طريق الضغط لفترة طويلة على شاشتك الرئيسية.</string>
|
||||||
|
<string name="import_warning_attendees">لم يتم استيراد قوائم الضيوف.</string>
|
||||||
|
<string name="settings_reliable_delivery_exempt">معفى من تحسين البطارية — تصل التذكيرات في الوقت المحدد.</string>
|
||||||
|
<string name="settings_reliable_delivery">توصيل موثوق</string>
|
||||||
|
<string name="settings_allday_reminder_time_hint">تنطلق التذكيرات لأحداث التي تستمر طوال اليوم عند %1$s</string>
|
||||||
|
<string name="agenda_range_override_hint">فقط للآن — يعيد التعيين إلى المدى المحفوظ الخاص بك عند إعادة فتح Calendula.</string>
|
||||||
|
<string name="settings_past_events_sample_morning">مراجعة الفريق</string>
|
||||||
|
<string name="settings_past_events_sample_midday">غداء مع روبن</string>
|
||||||
|
<string name="settings_past_events_sample_evening">ممارسة الجوقة</string>
|
||||||
|
<string name="settings_special_dates_last_synced">آخر مزامنة %1$s</string>
|
||||||
|
<string name="settings_special_dates_disable_type_message">يؤدي هذا لحذف التقويم ”%1$s“ وأحداثه. سيتم فقدان أي تذكيرات أو ملاحظات أضفتها إليه.</string>
|
||||||
|
<string name="special_dates_default_title_birthday">عيد ميلاد {name} ({year})</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -12,14 +12,14 @@
|
|||||||
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
|
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
|
||||||
<!-- Permission-Flow (F1) -->
|
<!-- Permission-Flow (F1) -->
|
||||||
<string name="permission_rationale_title">Alle Termine, schön im Blick</string>
|
<string name="permission_rationale_title">Alle Termine, schön im Blick</string>
|
||||||
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr braucht sie nicht um zu funktionieren — und nichts verlässt dein Gerät.</string>
|
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten.</string>
|
||||||
<string name="permission_request_button">Kalender-Zugriff erlauben</string>
|
<string name="permission_request_button">Kalender-Zugriff erlauben</string>
|
||||||
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</string>
|
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</string>
|
||||||
<string name="permission_denied_body">Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.</string>
|
<string name="permission_denied_body">Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.</string>
|
||||||
<string name="permission_open_settings_button">System-Einstellungen öffnen</string>
|
<string name="permission_open_settings_button">System-Einstellungen öffnen</string>
|
||||||
<string name="permission_retry_button">Erneut versuchen</string>
|
<string name="permission_retry_button">Erneut versuchen</string>
|
||||||
<string name="permission_benefit_private_title">Bleibt auf deinem Gerät</string>
|
<string name="permission_benefit_private_title">Datenschutz by Design</string>
|
||||||
<string name="permission_benefit_private_body">Deine Kalender werden lokal gelesen und verlassen das Telefon nie.</string>
|
<string name="permission_benefit_private_body">Keine Internetberechtigung, keine Telemetrie — nichts verlässt dein Telefon.</string>
|
||||||
<string name="permission_benefit_sync_title">Alle Kalender vereint</string>
|
<string name="permission_benefit_sync_title">Alle Kalender vereint</string>
|
||||||
<string name="permission_benefit_sync_body">Google, CalDAV, lokal — alles, was synchronisiert ist, erscheint automatisch.</string>
|
<string name="permission_benefit_sync_body">Google, CalDAV, lokal — alles, was synchronisiert ist, erscheint automatisch.</string>
|
||||||
<string name="permission_benefit_privacy_title">Kein Tracking, niemals</string>
|
<string name="permission_benefit_privacy_title">Kein Tracking, niemals</string>
|
||||||
@@ -310,9 +310,9 @@
|
|||||||
<string name="settings_language">App-Sprache</string>
|
<string name="settings_language">App-Sprache</string>
|
||||||
<string name="settings_language_auto">Systemstandard</string>
|
<string name="settings_language_auto">Systemstandard</string>
|
||||||
<!-- Hub category subtitles -->
|
<!-- Hub category subtitles -->
|
||||||
<string name="settings_appearance_subtitle">Design, Standardansicht, Wochenstart</string>
|
<string name="settings_appearance_subtitle">Theme, Farben, Schriftarten</string>
|
||||||
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
|
<string name="settings_event_form_subtitle">Standardfelder und Verhalten</string>
|
||||||
<string name="settings_notifications_subtitle">Termin-Erinnerungen</string>
|
<string name="settings_notifications_subtitle">Erinnerungen und Zustellung</string>
|
||||||
<string name="settings_section_about">Über</string>
|
<string name="settings_section_about">Über</string>
|
||||||
<string name="settings_license">Lizenz</string>
|
<string name="settings_license">Lizenz</string>
|
||||||
<string name="settings_license_value">MIT</string>
|
<string name="settings_license_value">MIT</string>
|
||||||
@@ -424,7 +424,7 @@
|
|||||||
<string name="settings_calendar_reminders_title">Erinnerungen pro Kalender</string>
|
<string name="settings_calendar_reminders_title">Erinnerungen pro Kalender</string>
|
||||||
<string name="settings_translate">Hilf beim Übersetzen</string>
|
<string name="settings_translate">Hilf beim Übersetzen</string>
|
||||||
<string name="settings_translate_hint">Füge eine Sprache auf Weblate hinzu oder verbessere sie</string>
|
<string name="settings_translate_hint">Füge eine Sprache auf Weblate hinzu oder verbessere sie</string>
|
||||||
<string name="settings_views_subtitle">Schnellwechsellnopf und Menüreihenfolge</string>
|
<string name="settings_views_subtitle">Standartansicht, Anordnung, Menüreihenfolge</string>
|
||||||
<string name="settings_special_dates_subtitle">Kontakte-Geburtstage und Jubiläen</string>
|
<string name="settings_special_dates_subtitle">Kontakte-Geburtstage und Jubiläen</string>
|
||||||
<string name="settings_section_special_dates">Besondere Termine von Kontakten</string>
|
<string name="settings_section_special_dates">Besondere Termine von Kontakten</string>
|
||||||
<string name="settings_special_dates_enable">Kontakttermine anzeigen</string>
|
<string name="settings_special_dates_enable">Kontakttermine anzeigen</string>
|
||||||
@@ -490,4 +490,162 @@
|
|||||||
</plurals>
|
</plurals>
|
||||||
<string name="event_detail_duplicate">Duplikate</string>
|
<string name="event_detail_duplicate">Duplikate</string>
|
||||||
<string name="reminder_day_tomorrow">Morgen</string>
|
<string name="reminder_day_tomorrow">Morgen</string>
|
||||||
|
<string name="event_edit_timezone_device">Zeitzone des Gerätes</string>
|
||||||
|
<string name="event_edit_timezone_device_summary">Passt sich an wo Sie sind</string>
|
||||||
|
<string name="event_edit_timezone_search">Zeitzonen durchsuchen</string>
|
||||||
|
<string name="event_edit_timezone_recent">Kürzliche</string>
|
||||||
|
<string name="event_edit_timezone_all">Alle Zeitzonen</string>
|
||||||
|
<string name="event_edit_timezone_none">Keine Zeitzone passt zu “%1$s”</string>
|
||||||
|
<string name="event_edit_timezone_local_time">%1$s deiner Zeit</string>
|
||||||
|
<string name="import_reminder_prompt_title">Deine Standarterinnerung anwenden?</string>
|
||||||
|
<plurals name="import_reminder_prompt_body_existing">
|
||||||
|
<item quantity="one">Dieses Event wurde mit %1$d Erinnerung importiert.</item>
|
||||||
|
<item quantity="other">Dieses Event wurde mit %1$d Erinnerungen importiert.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="settings_widget_size">Agenda-Widget-Größe</string>
|
||||||
|
<string name="import_reminder_prompt_body_none">Dieser Termin wurde ohne eine Erinnerung importiert.</string>
|
||||||
|
<string name="import_reminder_prompt_apply">Standart anwenden</string>
|
||||||
|
<string name="settings_widgets_subtitle">Agenda-Widget, Schnelleinstellungen-Kachel</string>
|
||||||
|
<string name="import_reminder_prompt_keep">unverändert lassen</string>
|
||||||
|
<string name="event_access_public_summary">Jeder mit Zugriff sieht jedes Detail</string>
|
||||||
|
<string name="event_access_private_summary">Andere sehen nur das du beschäftigt bist</string>
|
||||||
|
<string name="event_edit_recurrence_next">Als nächstes: %1$s</string>
|
||||||
|
<string name="event_edit_recurrence_incomplete">Gebe eine Zahl zwischen 1 und 999 ein</string>
|
||||||
|
<string name="timeline_scale_custom">Benutzerdefiniert</string>
|
||||||
|
<string name="settings_calendar_duration_inherits">Standard (%1$s)</string>
|
||||||
|
<string name="settings_group_about">Über</string>
|
||||||
|
<string name="event_move_gone">Dieser Termin existiert nicht mehr</string>
|
||||||
|
<string name="duration_custom_max">Maximal %1$s</string>
|
||||||
|
<string name="reminder_day_yesterday">Gestern</string>
|
||||||
|
<string name="agenda_no_more_today">Keine Events mehr heute</string>
|
||||||
|
<string name="agenda_span_starts">Beginnt %1$s</string>
|
||||||
|
<string name="agenda_span_ends">Endet %1$s</string>
|
||||||
|
<string name="today_jump_action">Zu Heute springen</string>
|
||||||
|
<string name="event_move_action">Verschieben…</string>
|
||||||
|
<string name="event_move_recurring_title">Wiederkehrenden Termin verschieben</string>
|
||||||
|
<string name="event_move_occurrence_only">Die ganze Serie zu verschieben würde ändern, auf welche Tage sie fällt, deshalb kann nur dieser Termin verschoben werden.</string>
|
||||||
|
<string name="event_move_done">Verschoben auf %1$s</string>
|
||||||
|
<string name="event_move_undo">Rückgängig</string>
|
||||||
|
<string name="event_move_undone">Verschieben rückgängig gemacht</string>
|
||||||
|
<string name="event_move_failed">Termin konnte nicht verschoben werden</string>
|
||||||
|
<string name="event_move_write_denied">Calendula braucht Schreibzugriff, um Termine zu verschieben</string>
|
||||||
|
<string name="event_move_blocked_series_end">Ein Termin kann nicht über das Ende seiner Serie hinaus verschoben werden</string>
|
||||||
|
<string name="event_edit_recurrence_next_none">Diese Regel wiederholt sich nie</string>
|
||||||
|
<string name="event_access_default_summary">Was dieser Kalender normalerweise macht</string>
|
||||||
|
<string name="event_access_confidential_summary">Als vertraulich markiert; was das bedeutet, entscheidet das Kalenderkonto</string>
|
||||||
|
<string name="onboarding_step_counter">Schritt %1$d von %2$d</string>
|
||||||
|
<string name="onboarding_backup_title">Deine Termine liegen nur hier</string>
|
||||||
|
<string name="onboarding_backup_body">Nichts auf diesem Telefon wird mit einem Konto synchronisiert – geht es verloren, ist auch dein Kalender weg. Calendula kann eine Sicherung für dich schreiben.</string>
|
||||||
|
<string name="onboarding_backup_benefit_folder_title">Ein Ordner deiner Wahl</string>
|
||||||
|
<string name="onboarding_backup_benefit_folder_body">Sicherungen sind einfache .ics-Dateien – lege sie irgendwohin, wo synchronisiert wird, oder auf eine SD-Karte.</string>
|
||||||
|
<string name="onboarding_backup_benefit_daily_title">Einmal am Tag, von selbst</string>
|
||||||
|
<string name="onboarding_backup_benefit_daily_body">Calendula exportiert deine lokalen Kalender im Hintergrund. Wie oft, änderst du in den Einstellungen.</string>
|
||||||
|
<string name="onboarding_backup_enable_button">Ordner wählen und sichern</string>
|
||||||
|
<string name="onboarding_view_title">Was soll zuerst geöffnet werden?</string>
|
||||||
|
<string name="onboarding_month_style_title">Wie soll Monat aussehen?</string>
|
||||||
|
<string name="onboarding_view_continue_button">Weiter</string>
|
||||||
|
<string name="onboarding_visibility_button">Alles klar</string>
|
||||||
|
<string name="onboarding_done_title">Alles bereit</string>
|
||||||
|
<string name="onboarding_done_body">Deine Kalender sind eingerichtet. Alles, was du gerade gewählt hast, lässt sich später in den Einstellungen ändern.</string>
|
||||||
|
<string name="onboarding_done_button">Meinen Kalender öffnen</string>
|
||||||
|
<plurals name="search_selected_count">
|
||||||
|
<item quantity="one">%d ausgewählt</item>
|
||||||
|
<item quantity="other">%d ausgewählt</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="search_in_descriptions">In Beschreibungen gefunden</string>
|
||||||
|
<string name="search_selection_close">Auswahl abbrechen</string>
|
||||||
|
<string name="search_select_all">Alle auswählen</string>
|
||||||
|
<string name="search_delete_selected">Ausgewählte löschen</string>
|
||||||
|
<string name="search_delete_partial">%1$d gelöscht, %2$d nicht</string>
|
||||||
|
<string name="search_delete_denied_partial">%1$d gelöscht, dann wurde der Schreibzugriff entzogen</string>
|
||||||
|
<string name="settings_theme_hint">Ob die App hell oder dunkel ist. Die Auswahl gilt sofort.</string>
|
||||||
|
<string name="settings_theme_system_summary">Aktuell %1$s</string>
|
||||||
|
<string name="settings_soften_colors">Kalenderfarben harmonisieren</string>
|
||||||
|
<string name="settings_soften_colors_summary">Behält den Farbton jedes Kalenders, gleicht aber seine Helligkeit an, damit jeder Termin lesbar bleibt und die Farben zusammenpassen. Ausschalten, um die Originalfarben aus der Kalenderquelle zu zeigen.</string>
|
||||||
|
<string name="settings_font_specimen_heading">Donnerstag, 14. Mai</string>
|
||||||
|
<string name="settings_font_specimen_body">Team-Besprechung um 10:00, danach Mittagessen mit Robin im Café am Marktplatz.</string>
|
||||||
|
<string name="settings_week_start_auto_summary">Aktuell %1$s</string>
|
||||||
|
<string name="settings_today_toolbar">Heute-Schaltfläche in der Symbolleiste</string>
|
||||||
|
<string name="settings_today_toolbar_summary">Zeigt die Schaltfläche für den Sprung zu heute in der Symbolleiste statt als schwebende Schaltfläche</string>
|
||||||
|
<string name="settings_app_name">App-Name</string>
|
||||||
|
<string name="settings_app_name_summary">Zeigt Calendula in deinem Launcher als „Kalender“. Nur der Name im Launcher ändert sich; das Symbol kann nach dem Wechsel an einer neuen Stelle liegen.</string>
|
||||||
|
<string name="settings_time_format_auto_summary">Folgt dem System: %1$s</string>
|
||||||
|
<string name="settings_timeline_scale">Stundenhöhe</string>
|
||||||
|
<string name="settings_timeline_scale_hint">Wie viel senkrechten Platz eine Stunde in der Wochen- und Tagesansicht einnimmt. Beide Ansichten teilen sich diese Einstellung. Du kannst die Zeitleiste auch mit zwei Fingern aufziehen, um jede Höhe dazwischen einzustellen.</string>
|
||||||
|
<string name="timeline_scale_fit_day">Ganzer Tag</string>
|
||||||
|
<string name="timeline_scale_fit_day_summary">Alle 24 Stunden auf einem Bildschirm, ohne Scrollen</string>
|
||||||
|
<string name="timeline_scale_compact">Kompakt</string>
|
||||||
|
<string name="timeline_scale_compact_summary">Mehr Stunden pro Bildschirm, kleinere Blöcke</string>
|
||||||
|
<string name="timeline_scale_regular">Normal</string>
|
||||||
|
<string name="timeline_scale_regular_summary">Der Standardabstand</string>
|
||||||
|
<string name="timeline_scale_comfortable">Komfortabel</string>
|
||||||
|
<string name="timeline_scale_comfortable_summary">Größere Blöcke, mehr Scrollen</string>
|
||||||
|
<string name="timeline_scale_custom_summary">Die Höhe, auf die du die Zeitleiste gezogen hast</string>
|
||||||
|
<string name="settings_past_events_sample_morning">Team-Besprechung</string>
|
||||||
|
<string name="settings_past_events_sample_midday">Mittagessen mit Robin</string>
|
||||||
|
<string name="settings_past_events_sample_evening">Chorprobe</string>
|
||||||
|
<string name="settings_widget_size_hint">Wie groß das Agenda-Widget auf dem Startbildschirm seinen Text zeichnet. Für das Monats-Widget gibt es keine Einstellung – sein Raster passt sich immer dem Platz an, den du ihm gibst.</string>
|
||||||
|
<string name="settings_widget_size_small">Klein</string>
|
||||||
|
<string name="settings_widget_size_medium">Mittel</string>
|
||||||
|
<string name="settings_widget_size_large">Groß</string>
|
||||||
|
<string name="settings_widget_size_extra_large">Sehr groß</string>
|
||||||
|
<string name="settings_widget_size_summary">Termintext %1$d%%</string>
|
||||||
|
<string name="settings_agenda_show_today">Heute immer zeigen</string>
|
||||||
|
<string name="settings_agenda_show_today_hint">Behält Heute oben in der Agenda und ihrem Widget, auch wenn für heute nichts mehr ansteht.</string>
|
||||||
|
<string name="settings_month_header">Monatsansicht</string>
|
||||||
|
<string name="settings_month_view_style">Stil der Monatsansicht</string>
|
||||||
|
<string name="month_style_paged">Seiten</string>
|
||||||
|
<string name="month_style_paged_summary">Ein Monat füllt den Bildschirm. Wische nach links oder rechts, um den Monat zu wechseln.</string>
|
||||||
|
<string name="month_style_continuous">Fortlaufende Monate</string>
|
||||||
|
<string name="month_style_continuous_summary">Jeder Monat steht unter seiner eigenen Überschrift, mit etwas Abstand zum nächsten.</string>
|
||||||
|
<string name="month_style_dense">Nahtlose Wochen</string>
|
||||||
|
<string name="month_style_dense_summary">Die Wochen laufen ohne Unterbrechung weiter, jeder Monat geht ohne Lücke direkt in den nächsten über.</string>
|
||||||
|
<string name="month_style_split">Geteilt</string>
|
||||||
|
<string name="month_style_split_summary">Ein kompaktes Raster markiert die Tage mit Terminen, und der angetippte Tag wird darunter aufgelistet.</string>
|
||||||
|
<string name="month_split_no_events">Nichts geplant</string>
|
||||||
|
<string name="month_split_expand">Ganzen Monat zeigen</string>
|
||||||
|
<string name="month_split_collapse">Termine des Tages zeigen</string>
|
||||||
|
<string name="settings_event_duration">Standarddauer</string>
|
||||||
|
<string name="settings_event_duration_hint">Wie lange ein neuer Termin dauert, bis du seine Endzeit änderst. Ganztägige Termine sind nicht betroffen.</string>
|
||||||
|
<string name="settings_calendar_durations_title">Dauer pro Kalender</string>
|
||||||
|
<string name="settings_calendar_durations_hint">Gib einem Kalender seine eigene Standarddauer – z. B. 8 Stunden für Arbeitsschichten.</string>
|
||||||
|
<string name="settings_calendar_duration_use_default">Standarddauer verwenden (%1$s)</string>
|
||||||
|
<string name="settings_allday_reminder_fires_at">Benachrichtigt um %1$s</string>
|
||||||
|
<string name="settings_group_look">Aussehen & Verhalten</string>
|
||||||
|
<string name="settings_group_data">Daten</string>
|
||||||
|
<string name="settings_group_app">App</string>
|
||||||
|
<string name="settings_backup_subtitle">Export, Import, automatische Sicherung</string>
|
||||||
|
<string name="settings_section_widgets">Widgets & Kacheln</string>
|
||||||
|
<string name="settings_widgets_hint">Einstellungen für die Widgets auf dem Startbildschirm und die Schnelleinstellungen-Kachel. Füge ein Widget hinzu, indem du lange auf den Startbildschirm drückst.</string>
|
||||||
|
<string name="settings_section_backup">Sicherung & Wiederherstellung</string>
|
||||||
|
<string name="settings_views_all_header">Alle Ansichten</string>
|
||||||
|
<string name="settings_week_day_header">Woche & Tag</string>
|
||||||
|
<string name="settings_default_view_hint">Die Ansicht, mit der Calendula startet.</string>
|
||||||
|
<string name="settings_week_start_hint">Der Tag, mit dem jede Woche beginnt – in allen Ansichten und Widgets.</string>
|
||||||
|
<string name="settings_time_format_hint">Wie Zeiten in der App geschrieben werden. Automatisch folgt deiner Systemeinstellung.</string>
|
||||||
|
<string name="settings_past_events_hint">Was die Agenda mit Terminen macht, die bereits vorbei sind.</string>
|
||||||
|
<string name="settings_dynamic_color_summary">Nimmt die Farben der App von deinem Hintergrundbild.</string>
|
||||||
|
<string name="settings_about_privacy">Datenschutzerklärung</string>
|
||||||
|
<string name="calendars_visibility_hint">Schalte einen Kalender aus, um ihn auf diesem Gerät zu verbergen – seine Termine verschwinden aus der App und er erinnert dich nicht mehr. Es ist derselbe Schalter, den deine anderen Kalender-Apps verwenden, sie verbergen ihn also ebenfalls. Nichts wird gelöscht, kein anderes Gerät ist betroffen, und du kannst ihn hier jederzeit wieder einschalten.</string>
|
||||||
|
<string name="calendars_visibility_a11y">„%1$s“ anzeigen</string>
|
||||||
|
<string name="calendars_visibility_notice_title">Einige Kalender sind ausgeschaltet</string>
|
||||||
|
<string name="calendars_visibility_notice_message">Calendula zeigt die Kalender, die für dieses Gerät eingeschaltet sind, und einige von deinen sind aus. Schalte sie unter Einstellungen → Kalender ein, um ihre Termine zu sehen.</string>
|
||||||
|
<string name="calendar_picker_missing_title">Fehlt ein Kalender?</string>
|
||||||
|
<string name="calendar_picker_missing_summary">Er kann ausgeschaltet, schreibgeschützt oder aus deinen Kontakten gefüllt sein – verwalte deine Kalender hier.</string>
|
||||||
|
<string name="calendars_state_read_only">Schreibgeschützt</string>
|
||||||
|
<string name="calendars_state_not_synced">Nicht auf dieses Gerät synchronisiert</string>
|
||||||
|
<string name="calendars_state_managed">Aus deinen Kontakten gefüllt</string>
|
||||||
|
<string name="calendars_managed_delete_locked">Dieser Kalender wird aus deinen Kontakten gefüllt, Calendula würde ihn bei der nächsten Synchronisierung neu anlegen. Schalte die besonderen Termine unter Einstellungen → Besondere Termine aus, um ihn zu löschen.</string>
|
||||||
|
<string name="calendars_account_from_source">%1$s (%2$s)</string>
|
||||||
|
<string name="duration_hours_minutes">%1$s %2$s</string>
|
||||||
|
<string name="onboarding_backup_skip_button">Nicht jetzt</string>
|
||||||
|
<string name="onboarding_back">Zurück</string>
|
||||||
|
<plurals name="search_delete_title">
|
||||||
|
<item quantity="one">%d Termin löschen?</item>
|
||||||
|
<item quantity="other">%d Termine löschen?</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="search_delete_done">
|
||||||
|
<item quantity="one">%d Termin gelöscht</item>
|
||||||
|
<item quantity="other">%d Termine gelöscht</item>
|
||||||
|
</plurals>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -491,4 +491,5 @@
|
|||||||
<item quantity="many">Importando %d eventos</item>
|
<item quantity="many">Importando %d eventos</item>
|
||||||
<item quantity="other">Importando %d eventos</item>
|
<item quantity="other">Importando %d eventos</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<string name="settings_soften_colors">Armoniza los colores del calendario</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
<string name="event_detail_share">Partager</string>
|
<string name="event_detail_share">Partager</string>
|
||||||
<string name="event_share_chooser_title">Evénement partagé</string>
|
<string name="event_share_chooser_title">Evénement partagé</string>
|
||||||
<string name="event_share_failed">Impossible de partager cet événement.</string>
|
<string name="event_share_failed">Impossible de partager cet événement.</string>
|
||||||
<string name="event_delete_title">Evénement supprimé ?</string>
|
<string name="event_delete_title">Evénement supprimé ?</string>
|
||||||
<string name="event_delete_body">Cet événement est retiré de votre calendrier et de chaque appareil auquel il est synchronisé.</string>
|
<string name="event_delete_body">Cet événement est retiré de votre calendrier et de chaque appareil auquel il est synchronisé.</string>
|
||||||
<string name="event_delete_recurring_title">Supprimer l\'événement récurrent</string>
|
<string name="event_delete_recurring_title">Supprimer l\'événement récurrent</string>
|
||||||
<string name="event_delete_option_occurrence">Seulement cet événement</string>
|
<string name="event_delete_option_occurrence">Seulement cet événement</string>
|
||||||
@@ -95,14 +95,14 @@
|
|||||||
<string name="event_edit_color_unsupported_hint">Ce calendrier ne propose aucun ensemble de couleurs. Vous pouvez autoriser des couleurs personnalisées pour ces calendriers dans les paramètres.</string>
|
<string name="event_edit_color_unsupported_hint">Ce calendrier ne propose aucun ensemble de couleurs. Vous pouvez autoriser des couleurs personnalisées pour ces calendriers dans les paramètres.</string>
|
||||||
<string name="event_edit_color_sync_warning">Ce calendrier pourrait retirer ou remplacer la couleur lors de sa prochaine synchronisation.</string>
|
<string name="event_edit_color_sync_warning">Ce calendrier pourrait retirer ou remplacer la couleur lors de sa prochaine synchronisation.</string>
|
||||||
<string name="event_edit_conflict_title">L\'événement a changé ailleurs</string>
|
<string name="event_edit_conflict_title">L\'événement a changé ailleurs</string>
|
||||||
<string name="event_edit_conflict_body">Durant l\'édition, cet événement a été altéré - par la synchronisation ou une autre application. Voulez-vous enregistrer ou annuler vos modifications ?</string>
|
<string name="event_edit_conflict_body">Durant l\'édition, cet événement a été altéré - par la synchronisation ou une autre application. Voulez-vous enregistrer ou annuler vos modifications ?</string>
|
||||||
<string name="event_edit_conflict_overwrite">Enregistrer mes modifications</string>
|
<string name="event_edit_conflict_overwrite">Enregistrer mes modifications</string>
|
||||||
<string name="event_edit_conflict_overwrite_hint">Seuls les champs que vous modifiez remplacent l\'altération externe</string>
|
<string name="event_edit_conflict_overwrite_hint">Seuls les champs que vous modifiez remplacent l\'altération externe</string>
|
||||||
<string name="event_edit_conflict_discard">Annuler mes modifications</string>
|
<string name="event_edit_conflict_discard">Annuler mes modifications</string>
|
||||||
<string name="event_edit_conflict_discard_hint">L’événement reste tel qu’il est maintenant</string>
|
<string name="event_edit_conflict_discard_hint">L’événement reste tel qu’il est maintenant</string>
|
||||||
<string name="event_edit_gone_title">Evénement supprimé</string>
|
<string name="event_edit_gone_title">Evénement supprimé</string>
|
||||||
<string name="event_edit_gone_body">Cet événement a été supprimé entre-temps, par exemple sur un autre appareil. Vos modifications ne peuvent plus être enregistrées.</string>
|
<string name="event_edit_gone_body">Cet événement a été supprimé entre-temps, par exemple sur un autre appareil. Vos modifications ne peuvent plus être enregistrées.</string>
|
||||||
<string name="import_reminder_prompt_title">Appliquer votre rappel par défaut ?</string>
|
<string name="import_reminder_prompt_title">Appliquer votre rappel par défaut ?</string>
|
||||||
<string name="import_reminder_prompt_body_none">Cet événement a été importé sans aucun rappel.</string>
|
<string name="import_reminder_prompt_body_none">Cet événement a été importé sans aucun rappel.</string>
|
||||||
<plurals name="import_reminder_prompt_body_existing">
|
<plurals name="import_reminder_prompt_body_existing">
|
||||||
<item quantity="one">Cet événement a été importé avec %1$d rappel.</item>
|
<item quantity="one">Cet événement a été importé avec %1$d rappel.</item>
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
<string name="event_access_confidential">Confidentiel</string>
|
<string name="event_access_confidential">Confidentiel</string>
|
||||||
<string name="event_attendee_organizer">Organisateur</string>
|
<string name="event_attendee_organizer">Organisateur</string>
|
||||||
<string name="event_attendee_resource">Ressource</string>
|
<string name="event_attendee_resource">Ressource</string>
|
||||||
<string name="event_detail_self_response">Votre réponse : %1$s</string>
|
<string name="event_detail_self_response">Votre réponse : %1$s</string>
|
||||||
<string name="reminder_default">Rappel par défaut</string>
|
<string name="reminder_default">Rappel par défaut</string>
|
||||||
<plurals name="reminder_minutes">
|
<plurals name="reminder_minutes">
|
||||||
<item quantity="one">%d minute avant</item>
|
<item quantity="one">%d minute avant</item>
|
||||||
@@ -283,7 +283,7 @@
|
|||||||
<string name="event_status_tentative">Provisoire</string>
|
<string name="event_status_tentative">Provisoire</string>
|
||||||
<string name="reminder_at_time">Au moment de l’événement</string>
|
<string name="reminder_at_time">Au moment de l’événement</string>
|
||||||
<string name="reminder_onboarding_body">Android n’affiche pas de rappels d’événements par lui-même — une application de calendrier doit le faire. Laissez Calendula faire ce travail.</string>
|
<string name="reminder_onboarding_body">Android n’affiche pas de rappels d’événements par lui-même — une application de calendrier doit le faire. Laissez Calendula faire ce travail.</string>
|
||||||
<string name="reminder_benefit_duplicates_title">Utiliser une deuxième application de calendrier ?</string>
|
<string name="reminder_benefit_duplicates_title">Utiliser une deuxième application de calendrier ?</string>
|
||||||
<string name="reminder_benefit_duplicates_body">Si une autre application publie également des rappels, vous les verrez deux fois, désactivez-les là ou ici.</string>
|
<string name="reminder_benefit_duplicates_body">Si une autre application publie également des rappels, vous les verrez deux fois, désactivez-les là ou ici.</string>
|
||||||
<string name="reminder_benefit_reversible_title">modifier à tout moment</string>
|
<string name="reminder_benefit_reversible_title">modifier à tout moment</string>
|
||||||
<string name="reminder_benefit_reversible_body">Le commutateur se trouve dans les paramètres, sous Notifications.</string>
|
<string name="reminder_benefit_reversible_body">Le commutateur se trouve dans les paramètres, sous Notifications.</string>
|
||||||
@@ -331,14 +331,14 @@
|
|||||||
<string name="settings_drawer_order_hint">Faites glisser pour réorganiser les vues répertoriées dans le menu de navigation.</string>
|
<string name="settings_drawer_order_hint">Faites glisser pour réorganiser les vues répertoriées dans le menu de navigation.</string>
|
||||||
<string name="reorder_drag_handle">Faites glisser pour réorganiser</string>
|
<string name="reorder_drag_handle">Faites glisser pour réorganiser</string>
|
||||||
<string name="settings_section_event_form">Nouveau formulaire d’événement</string>
|
<string name="settings_section_event_form">Nouveau formulaire d’événement</string>
|
||||||
<string name="settings_form_fields_hint">Champs affichés par défaut — tout le reste se trouve derrière « Plus de champs »</string>
|
<string name="settings_form_fields_hint">Champs affichés par défaut — tout le reste se trouve derrière « Plus de champs »</string>
|
||||||
<string name="settings_autofocus_title">Titre principal du nouvel événement</string>
|
<string name="settings_autofocus_title">Titre principal du nouvel événement</string>
|
||||||
<string name="settings_autofocus_title_hint">Lorsque vous démarrez un nouvel événement, placez le curseur dans le champ du titre et ouvrez immédiatement le clavier.</string>
|
<string name="settings_autofocus_title_hint">Lorsque vous démarrez un nouvel événement, placez le curseur dans le champ du titre et ouvrez immédiatement le clavier.</string>
|
||||||
<string name="settings_color_unsupported">Autoriser les couleurs dans les calendriers non pris en charge</string>
|
<string name="settings_color_unsupported">Autoriser les couleurs dans les calendriers non pris en charge</string>
|
||||||
<string name="settings_color_unsupported_hint">Certains calendriers (par exemple, certains CalDAV) ne publient aucun ensemble de couleurs, une couleur d’événement personnalisée peut être supprimée ou remplacée lors de leur prochaine synchronisation. C’est une limitation de ces calendriers, pas quelque chose que Calendula peut réparer.</string>
|
<string name="settings_color_unsupported_hint">Certains calendriers (par exemple, certains CalDAV) ne publient aucun ensemble de couleurs, une couleur d’événement personnalisée peut être supprimée ou remplacée lors de leur prochaine synchronisation. C’est une limitation de ces calendriers, pas quelque chose que Calendula peut réparer.</string>
|
||||||
<string name="settings_section_notifications">Notifications</string>
|
<string name="settings_section_notifications">Notifications</string>
|
||||||
<string name="settings_reminders">rappels d\'événements</string>
|
<string name="settings_reminders">rappels d\'événements</string>
|
||||||
<string name="settings_reminders_hint">Vous voyez des rappels deux fois ? Une autre application de calendrier les publie aussi — désactivez-les dans l’un des deux.</string>
|
<string name="settings_reminders_hint">Vous voyez des rappels deux fois ? Une autre application de calendrier les publie aussi — désactivez-les dans l’un des deux.</string>
|
||||||
<string name="settings_default_reminder">rappel par défaut</string>
|
<string name="settings_default_reminder">rappel par défaut</string>
|
||||||
<string name="settings_default_reminder_allday">événements d\'une journée entière</string>
|
<string name="settings_default_reminder_allday">événements d\'une journée entière</string>
|
||||||
<string name="settings_allday_reminder_time">Heure de rappel toute la journée</string>
|
<string name="settings_allday_reminder_time">Heure de rappel toute la journée</string>
|
||||||
@@ -383,9 +383,9 @@
|
|||||||
<string name="settings_calendar_reminders_managed_hint">Définir dans les dates spéciales de contact</string>
|
<string name="settings_calendar_reminders_managed_hint">Définir dans les dates spéciales de contact</string>
|
||||||
<string name="settings_special_dates_paused_title">suspendu</string>
|
<string name="settings_special_dates_paused_title">suspendu</string>
|
||||||
<string name="settings_special_dates_paused_hint">Calendula ne peut plus lire vos contacts, donc ces calendriers ne se mettent pas à jour.</string>
|
<string name="settings_special_dates_paused_hint">Calendula ne peut plus lire vos contacts, donc ces calendriers ne se mettent pas à jour.</string>
|
||||||
<string name="settings_special_dates_disable_title">Désactiver les dates de contact ?</string>
|
<string name="settings_special_dates_disable_title">Désactiver les dates de contact ?</string>
|
||||||
<string name="settings_special_dates_disable_all_message">Cela supprime les calendriers de contact et leurs événements. Tous les rappels ou notes que vous leur avez ajoutés seront perdus.</string>
|
<string name="settings_special_dates_disable_all_message">Cela supprime les calendriers de contact et leurs événements. Tous les rappels ou notes que vous leur avez ajoutés seront perdus.</string>
|
||||||
<string name="settings_special_dates_disable_type_message">Cela supprime le calendrier « %1$s » et ses événements. Tous les rappels ou notes que vous y avez ajoutés seront perdus.</string>
|
<string name="settings_special_dates_disable_type_message">Cela supprime le calendrier « %1$s » et ses événements. Tous les rappels ou notes que vous y avez ajoutés seront perdus.</string>
|
||||||
<string name="settings_special_dates_disable_confirm">désactiver</string>
|
<string name="settings_special_dates_disable_confirm">désactiver</string>
|
||||||
<string name="settings_section_about">A propos</string>
|
<string name="settings_section_about">A propos</string>
|
||||||
<string name="settings_license">Licence</string>
|
<string name="settings_license">Licence</string>
|
||||||
@@ -411,7 +411,7 @@
|
|||||||
<string name="calendars_edit_title">Editer un agenda</string>
|
<string name="calendars_edit_title">Editer un agenda</string>
|
||||||
<string name="calendars_name_label">Nom</string>
|
<string name="calendars_name_label">Nom</string>
|
||||||
<string name="calendars_description_hint">Ajouter une description</string>
|
<string name="calendars_description_hint">Ajouter une description</string>
|
||||||
<string name="calendars_delete_confirm_title">Supprimer un calendrier ?</string>
|
<string name="calendars_delete_confirm_title">Supprimer un calendrier ?</string>
|
||||||
<string name="calendars_delete_confirm_message">\"%1$s\" et tous ses événements seront définitivement retirés de cet appareil.</string>
|
<string name="calendars_delete_confirm_message">\"%1$s\" et tous ses événements seront définitivement retirés de cet appareil.</string>
|
||||||
<string name="calendars_write_error">Impossible de sauvegarder les changements.</string>
|
<string name="calendars_write_error">Impossible de sauvegarder les changements.</string>
|
||||||
<string name="calendars_backup_hint">Les calendriers locaux ne sont synchronisés nulle part, alors exportez-les vers un fichier .ics pour en garder une copie.</string>
|
<string name="calendars_backup_hint">Les calendriers locaux ne sont synchronisés nulle part, alors exportez-les vers un fichier .ics pour en garder une copie.</string>
|
||||||
@@ -429,8 +429,8 @@
|
|||||||
<string name="calendars_auto_backup_interval">Intervalle</string>
|
<string name="calendars_auto_backup_interval">Intervalle</string>
|
||||||
<string name="calendars_auto_backup_interval_min">Minimum 30 minutes.</string>
|
<string name="calendars_auto_backup_interval_min">Minimum 30 minutes.</string>
|
||||||
<string name="calendars_auto_backup_status_never">Pas encore de sauvegarde automatique</string>
|
<string name="calendars_auto_backup_status_never">Pas encore de sauvegarde automatique</string>
|
||||||
<string name="calendars_auto_backup_status_ok">Dernière sauvegarde : %1$s</string>
|
<string name="calendars_auto_backup_status_ok">Dernière sauvegarde : %1$s</string>
|
||||||
<string name="calendars_auto_backup_status_failed">La dernière sauvegarde a échoué : %1$s</string>
|
<string name="calendars_auto_backup_status_failed">La dernière sauvegarde a échoué : %1$s</string>
|
||||||
<string name="backup_channel_description">Avertit si les sauvegardes automatiques échouent de manière répétée.</string>
|
<string name="backup_channel_description">Avertit si les sauvegardes automatiques échouent de manière répétée.</string>
|
||||||
<string name="backup_failed_title">La sauvegarde automatique a échoué</string>
|
<string name="backup_failed_title">La sauvegarde automatique a échoué</string>
|
||||||
<string name="backup_failed_text">Calendula n’a pas pu écrire le fichier de sauvegarde. Vérifiez le dossier de sauvegarde dans les paramètres.</string>
|
<string name="backup_failed_text">Calendula n’a pas pu écrire le fichier de sauvegarde. Vérifiez le dossier de sauvegarde dans les paramètres.</string>
|
||||||
@@ -481,7 +481,7 @@
|
|||||||
</plurals>
|
</plurals>
|
||||||
<string name="shortcut_new_event_long">créer un événement</string>
|
<string name="shortcut_new_event_long">créer un événement</string>
|
||||||
<string name="settings_qs_tile">Ajouter des paramètres rapides</string>
|
<string name="settings_qs_tile">Ajouter des paramètres rapides</string>
|
||||||
<string name="settings_qs_tile_hint">Ajoutez un bouton « Nouvel événement » au panneau Paramètres rapides.</string>
|
<string name="settings_qs_tile_hint">Ajoutez un bouton « Nouvel événement » au panneau Paramètres rapides.</string>
|
||||||
<string name="crash_dialog_title">%1$s a planté</string>
|
<string name="crash_dialog_title">%1$s a planté</string>
|
||||||
<string name="crash_dialog_message">%1$s a été fermé de manière inattendue la dernière fois. Vous pouvez aider à le corriger en envoyant ce rapport en tant que problème. Il reste sur votre appareil jusqu’à ce que vous choisissiez de le partager, et n’inclut aucune donnée personnelle ni contenu de calendrier — seuls les détails techniques ci-dessous.</string>
|
<string name="crash_dialog_message">%1$s a été fermé de manière inattendue la dernière fois. Vous pouvez aider à le corriger en envoyant ce rapport en tant que problème. Il reste sur votre appareil jusqu’à ce que vous choisissiez de le partager, et n’inclut aucune donnée personnelle ni contenu de calendrier — seuls les détails techniques ci-dessous.</string>
|
||||||
<string name="crash_dialog_report">rapport</string>
|
<string name="crash_dialog_report">rapport</string>
|
||||||
|
|||||||
@@ -17,14 +17,14 @@
|
|||||||
<string name="state_failure_no_calendars_action">Apri le impostazioni calendario di sistema</string>
|
<string name="state_failure_no_calendars_action">Apri le impostazioni calendario di sistema</string>
|
||||||
<string name="state_failure_provider">Errore nella lettura del calendario.</string>
|
<string name="state_failure_provider">Errore nella lettura del calendario.</string>
|
||||||
<string name="permission_rationale_title">Gestisci magnificamente tutti i tuoi eventi</string>
|
<string name="permission_rationale_title">Gestisci magnificamente tutti i tuoi eventi</string>
|
||||||
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa di cui ha bisogno: nessuna informazione lascerà mai il tuo dispositivo.</string>
|
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi.</string>
|
||||||
<string name="permission_request_button">Concedi l\'accesso al calendario</string>
|
<string name="permission_request_button">Concedi l\'accesso al calendario</string>
|
||||||
<string name="permission_denied_title">Accesso al calendario negato</string>
|
<string name="permission_denied_title">Accesso al calendario negato</string>
|
||||||
<string name="permission_denied_body">Calendula non può mostrare gli eventi senza accesso al calendario. Puoi concederlo dalle impostazioni di sistema.</string>
|
<string name="permission_denied_body">Calendula non può mostrare gli eventi senza accesso al calendario. Puoi concederlo dalle impostazioni di sistema.</string>
|
||||||
<string name="permission_open_settings_button">Apri le impostazioni di sistema</string>
|
<string name="permission_open_settings_button">Apri le impostazioni di sistema</string>
|
||||||
<string name="permission_retry_button">Riprova</string>
|
<string name="permission_retry_button">Riprova</string>
|
||||||
<string name="permission_benefit_private_title">Rimane sul tuo dispositivo</string>
|
<string name="permission_benefit_private_title">Privato by design</string>
|
||||||
<string name="permission_benefit_private_body">I tuoi calendari sono letti localmente e non lasciano mai il tuo dispositivo.</string>
|
<string name="permission_benefit_private_body">Nessun accesso a internet, nessun tracking: nulla lascia il tuo dispositivo.</string>
|
||||||
<string name="permission_benefit_sync_title">Tutti i tuoi calendari, insieme</string>
|
<string name="permission_benefit_sync_title">Tutti i tuoi calendari, insieme</string>
|
||||||
<string name="permission_benefit_sync_body">Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo, semplicemente, compare.</string>
|
<string name="permission_benefit_sync_body">Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo, semplicemente, compare.</string>
|
||||||
<string name="permission_benefit_privacy_title">Nessun tracking, per sempre</string>
|
<string name="permission_benefit_privacy_title">Nessun tracking, per sempre</string>
|
||||||
@@ -301,8 +301,8 @@
|
|||||||
<string name="settings_language_auto">Lingua di sistema</string>
|
<string name="settings_language_auto">Lingua di sistema</string>
|
||||||
<string name="settings_translate">Aiuta a tradurre</string>
|
<string name="settings_translate">Aiuta a tradurre</string>
|
||||||
<string name="settings_translate_hint">Aggiungi o migliora una traduzione su Weblate</string>
|
<string name="settings_translate_hint">Aggiungi o migliora una traduzione su Weblate</string>
|
||||||
<string name="settings_appearance_subtitle">Tema, vista di default, inizio settimana</string>
|
<string name="settings_appearance_subtitle">Tema, colori, font</string>
|
||||||
<string name="settings_event_form_subtitle">Campi di default per nuovi eventi</string>
|
<string name="settings_event_form_subtitle">Campi di default e comportamento</string>
|
||||||
<string name="settings_section_about">A proposito</string>
|
<string name="settings_section_about">A proposito</string>
|
||||||
<string name="settings_license">Licenza</string>
|
<string name="settings_license">Licenza</string>
|
||||||
<string name="settings_license_value">MIT</string>
|
<string name="settings_license_value">MIT</string>
|
||||||
@@ -414,7 +414,7 @@
|
|||||||
<string name="agenda_range_this_week">Questa settimana</string>
|
<string name="agenda_range_this_week">Questa settimana</string>
|
||||||
<string name="settings_reminders">Promemoria per gli eventi</string>
|
<string name="settings_reminders">Promemoria per gli eventi</string>
|
||||||
<string name="settings_default_reminder">Promemoria per eventi standard</string>
|
<string name="settings_default_reminder">Promemoria per eventi standard</string>
|
||||||
<string name="settings_notifications_subtitle">Promemoria evento</string>
|
<string name="settings_notifications_subtitle">Promemoria</string>
|
||||||
<string name="shortcut_new_event_short">Nuovo evento</string>
|
<string name="shortcut_new_event_short">Nuovo evento</string>
|
||||||
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente.</string>
|
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente.</string>
|
||||||
<string name="settings_font_headings">Carattere del titolo</string>
|
<string name="settings_font_headings">Carattere del titolo</string>
|
||||||
@@ -423,13 +423,13 @@
|
|||||||
<string name="settings_font_choose_file">Scegli file…</string>
|
<string name="settings_font_choose_file">Scegli file…</string>
|
||||||
<string name="settings_font_custom_selected">Font personalizzato</string>
|
<string name="settings_font_custom_selected">Font personalizzato</string>
|
||||||
<string name="settings_font_import_failed">Impossibile leggere il file come font</string>
|
<string name="settings_font_import_failed">Impossibile leggere il file come font</string>
|
||||||
<string name="settings_section_views">Viste</string>
|
<string name="settings_section_views">Vista</string>
|
||||||
<string name="settings_quick_switch_header">Pulsante di cambio rapido</string>
|
<string name="settings_quick_switch_header">Pulsante di cambio rapido</string>
|
||||||
<string name="settings_quick_switch_hint">Scegli attraverso quali viste scorrere col pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione.</string>
|
<string name="settings_quick_switch_hint">Scegli attraverso quali viste scorrere col pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione.</string>
|
||||||
<string name="settings_drawer_order_header">Menù di navigazione</string>
|
<string name="settings_drawer_order_header">Menù di navigazione</string>
|
||||||
<string name="settings_drawer_order_hint">Trascina per riordinare le viste elencate nel menù di navigazione.</string>
|
<string name="settings_drawer_order_hint">Trascina per riordinare le viste elencate nel menù di navigazione.</string>
|
||||||
<string name="reorder_drag_handle">Trascina per riordinare</string>
|
<string name="reorder_drag_handle">Trascina per riordinare</string>
|
||||||
<string name="settings_views_subtitle">Ordine del pulsante di cambio rapido e del menù</string>
|
<string name="settings_views_subtitle">Vista di default, layout, ordine</string>
|
||||||
<string name="settings_special_dates_subtitle">Compleanni e anniversari dei contatti</string>
|
<string name="settings_special_dates_subtitle">Compleanni e anniversari dei contatti</string>
|
||||||
<string name="settings_section_special_dates">Date speciali dei contatti</string>
|
<string name="settings_section_special_dates">Date speciali dei contatti</string>
|
||||||
<string name="settings_special_dates_enable">Mostra date dei contatti</string>
|
<string name="settings_special_dates_enable">Mostra date dei contatti</string>
|
||||||
@@ -478,8 +478,8 @@
|
|||||||
<string name="reminder_day_yesterday">Ieri</string>
|
<string name="reminder_day_yesterday">Ieri</string>
|
||||||
<string name="agenda_no_more_today">Nessun altro evento per oggi</string>
|
<string name="agenda_no_more_today">Nessun altro evento per oggi</string>
|
||||||
<string name="back">Indietro</string>
|
<string name="back">Indietro</string>
|
||||||
<string name="settings_soften_colors">Attenua i colori del calendario</string>
|
<string name="settings_soften_colors">Armonizza i colori del calendario</string>
|
||||||
<string name="settings_soften_colors_summary">I colori dei calendari e degli eventi vengono attenuati per adattarsi al tema. Deseleziona per mostrare i colori naturali.</string>
|
<string name="settings_soften_colors_summary">Mantieni i colori del calendario ma attenua la luminosità, così gli eventi rimangono leggibili e i colori sono armonizzati. Deseleziona per mostrare i colori originali.</string>
|
||||||
<string name="settings_week_numbers">Numeri di settimana</string>
|
<string name="settings_week_numbers">Numeri di settimana</string>
|
||||||
<string name="settings_week_numbers_summary">Mostra i numeri di settimana nella vista Mese</string>
|
<string name="settings_week_numbers_summary">Mostra i numeri di settimana nella vista Mese</string>
|
||||||
<string name="settings_agenda_show_today">Mostra sempre Oggi</string>
|
<string name="settings_agenda_show_today">Mostra sempre Oggi</string>
|
||||||
@@ -499,4 +499,151 @@
|
|||||||
<item quantity="many">Importando %d eventi</item>
|
<item quantity="many">Importando %d eventi</item>
|
||||||
<item quantity="other">Importando %d eventi</item>
|
<item quantity="other">Importando %d eventi</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<string name="event_move_action">Sposta…</string>
|
||||||
|
<string name="event_move_recurring_title">Sposta un evento ricorrente</string>
|
||||||
|
<string name="event_move_done">Spostato al %1$s</string>
|
||||||
|
<string name="event_move_undo">Annulla ripianificazione</string>
|
||||||
|
<string name="event_move_undone">Ripianificazione annullata</string>
|
||||||
|
<string name="event_move_failed">Impossibile ripianificare l\'evento</string>
|
||||||
|
<string name="event_move_write_denied">Calendula ha bisogno dei permessi di scrittura per ripianificare gli eventi</string>
|
||||||
|
<string name="event_move_gone">Questo evento è stato eliminato.</string>
|
||||||
|
<string name="event_move_blocked_series_end">Non è possibile ripianificare un evento oltre la data di fine della sua serie</string>
|
||||||
|
<string name="event_edit_timezone_device">Fuso orario del dispositivo</string>
|
||||||
|
<string name="event_edit_timezone_device_summary">Segue il fuso orario in cui ti trovi</string>
|
||||||
|
<string name="event_edit_timezone_search">Seleziona fuso orario</string>
|
||||||
|
<string name="event_edit_timezone_recent">Recenti</string>
|
||||||
|
<string name="event_edit_timezone_all">Tutti i fusi orari</string>
|
||||||
|
<string name="event_edit_timezone_none">Nessun fuso corrisponde a \"%1$s\"</string>
|
||||||
|
<string name="event_edit_timezone_local_time">%1$s nel tuo fuso orario</string>
|
||||||
|
<string name="event_edit_recurrence_incomplete">Inserisci un numero tra 1 e 999</string>
|
||||||
|
<string name="event_edit_recurrence_next">Prossimo evento: %1$s</string>
|
||||||
|
<string name="event_edit_recurrence_next_none">Questa regola non si ripete</string>
|
||||||
|
<string name="event_access_default_summary">In base all\'impostazione del calendario</string>
|
||||||
|
<string name="event_access_public_summary">Chiunque acceda può vedere i dettagli completi</string>
|
||||||
|
<string name="event_access_private_summary">Gli altri vedranno solo che sei impegnato</string>
|
||||||
|
<string name="event_access_confidential_summary">Confidenziale; dipende dalle impostazioni dell\'account</string>
|
||||||
|
<string name="duration_hours_minutes">%1$s %2$s</string>
|
||||||
|
<string name="duration_custom_max">Al più %1$s</string>
|
||||||
|
<string name="onboarding_step_counter">Step %1$d di %2$d</string>
|
||||||
|
<string name="onboarding_backup_title">I tuoi eventi rimangono qui</string>
|
||||||
|
<string name="onboarding_backup_body">Nulla viene sincronizzato, perdere i dati presenti su questo dispositivo significa perdere i tuoi calendari. Calendula può fare un backup per te.</string>
|
||||||
|
<string name="onboarding_backup_benefit_folder_title">Una cartella a scelta</string>
|
||||||
|
<string name="onboarding_backup_benefit_folder_body">I backup sono file .ics. Mettili al sicuro sul cloud o su una scheda SD.</string>
|
||||||
|
<string name="onboarding_backup_benefit_daily_title">Giornaliero, in automatico</string>
|
||||||
|
<string name="onboarding_backup_benefit_daily_body">Calendula esporta i tuoi calendari locali in background. Selezione la frequenza nelle Impostazioni.</string>
|
||||||
|
<string name="onboarding_backup_enable_button">Seleziona la cartella ed esegui il backup</string>
|
||||||
|
<string name="onboarding_backup_skip_button">Non ora</string>
|
||||||
|
<string name="onboarding_view_title">Cosa viene aperto per primo?</string>
|
||||||
|
<string name="onboarding_month_style_title">Seleziona la vista Mese</string>
|
||||||
|
<string name="onboarding_view_continue_button">Continua</string>
|
||||||
|
<string name="onboarding_back">Indietro</string>
|
||||||
|
<string name="onboarding_visibility_button">Capito</string>
|
||||||
|
<string name="onboarding_done_title">Tutto fatto</string>
|
||||||
|
<string name="onboarding_done_body">I tuoi calendari sono pronti. Tutto ciò che hai scelto può essere modificato più tardi nelle Impostazioni.</string>
|
||||||
|
<string name="onboarding_done_button">Apri i miei calendari</string>
|
||||||
|
<string name="agenda_span_starts">Inizia alle %1$s</string>
|
||||||
|
<string name="agenda_span_ends">Finisce alle %1$s</string>
|
||||||
|
<string name="today_jump_action">Vai ad oggi</string>
|
||||||
|
<plurals name="search_selected_count">
|
||||||
|
<item quantity="one">%d selezionato</item>
|
||||||
|
<item quantity="many">%d selezionati</item>
|
||||||
|
<item quantity="other">%d selezionati</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="search_in_descriptions">Trovato nelle descrizioni</string>
|
||||||
|
<string name="search_selection_close">Cancella selezione</string>
|
||||||
|
<string name="search_select_all">Seleziona tutti</string>
|
||||||
|
<string name="search_delete_selected">Elimina i selezionati</string>
|
||||||
|
<plurals name="search_delete_title">
|
||||||
|
<item quantity="one">Eliminare l\'evento?</item>
|
||||||
|
<item quantity="other">Eliminare l\'evento?</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="search_delete_done">
|
||||||
|
<item quantity="one">Evento eliminato</item>
|
||||||
|
<item quantity="other">Evento eliminato</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="search_delete_partial">%1$d eliminati, %2$d no</string>
|
||||||
|
<string name="search_delete_denied_partial">%1$d eliminati, poi permessi di scrittura negati</string>
|
||||||
|
<string name="event_move_occurrence_only">Ripianificare l\'intera serie cambia i giorni in cui cade, quindi puoi ripianificare solo questo evento.</string>
|
||||||
|
<string name="settings_theme_hint">Tema chiaro o scuro. La modifica viene applicata immediatamente.</string>
|
||||||
|
<string name="settings_theme_system_summary">Al momento %1$s</string>
|
||||||
|
<string name="settings_font_specimen_heading">Giovedì, 14 Maggio</string>
|
||||||
|
<string name="settings_font_specimen_body">Team review alle 10:00, poi pranzo con Ugo al bar a Piazza di Spagna.</string>
|
||||||
|
<string name="settings_week_start_auto_summary">Al momento %1$s</string>
|
||||||
|
<string name="settings_today_toolbar">Pulsante Oggi nella toolbar</string>
|
||||||
|
<string name="settings_today_toolbar_summary">Mostra il pulsante \"vai alla data\" nella toolbar invece che come pulsante flottante</string>
|
||||||
|
<string name="settings_app_name">Nome app</string>
|
||||||
|
<string name="settings_app_name_summary">Mostra Calendula come \"Calendario\" nel launcher. Il nome cambia solo nel launcher; l\'icona potrebbe spostarsi dopo la modifica.</string>
|
||||||
|
<string name="settings_time_format_auto_summary">Da sistema: %1$s</string>
|
||||||
|
<string name="settings_timeline_scale">Altezza ore</string>
|
||||||
|
<string name="settings_timeline_scale_hint">Spazio occupato verticalmente da un\'ora nella vista Giorno e Settimana. Puoi anche modificare l\'altezza come preverisci pizzicando la timeline.</string>
|
||||||
|
<string name="timeline_scale_fit_day">Occupa la giornata intera</string>
|
||||||
|
<string name="timeline_scale_fit_day_summary">Tutte le 24 ore in una schermata, senza scrolling</string>
|
||||||
|
<string name="timeline_scale_compact">Compatto</string>
|
||||||
|
<string name="timeline_scale_compact_summary">Più ore per schermata, blocchi più piccoli</string>
|
||||||
|
<string name="timeline_scale_regular">Regolare</string>
|
||||||
|
<string name="timeline_scale_regular_summary">La spaziatura standard</string>
|
||||||
|
<string name="timeline_scale_comfortable">Comodo</string>
|
||||||
|
<string name="timeline_scale_comfortable_summary">Blocchi più ampi, più scrolling</string>
|
||||||
|
<string name="timeline_scale_custom">Personalizza</string>
|
||||||
|
<string name="timeline_scale_custom_summary">L\'altezza a cui hai portato la timeline</string>
|
||||||
|
<string name="settings_drag_to_reschedule">Trascina per ripianificare</string>
|
||||||
|
<string name="settings_drag_to_reschedule_summary">Ripianifica un evento trascinandolo in un altro giorno o ora</string>
|
||||||
|
<string name="settings_past_events_sample_morning">Team review</string>
|
||||||
|
<string name="settings_past_events_sample_midday">Pranzo con Ugo</string>
|
||||||
|
<string name="settings_past_events_sample_evening">Allenamento di scacchi</string>
|
||||||
|
<string name="settings_widget_size">Dimensioni del widget Agenda</string>
|
||||||
|
<string name="settings_widget_size_hint">Dimensioni del testo del widget agenda. Il widget mensile non ha impostazioni, si adatta da solo alla dimensione data.</string>
|
||||||
|
<string name="settings_widget_size_small">Piccolo</string>
|
||||||
|
<string name="settings_widget_size_medium">Medio</string>
|
||||||
|
<string name="settings_widget_size_large">Grande</string>
|
||||||
|
<string name="settings_widget_size_extra_large">Molto grande</string>
|
||||||
|
<string name="settings_widget_size_summary">Carattere %1$d%%</string>
|
||||||
|
<string name="settings_month_header">Vista Mese</string>
|
||||||
|
<string name="settings_month_view_style">Stile vista Mese</string>
|
||||||
|
<string name="month_style_paged">Pagine</string>
|
||||||
|
<string name="month_style_paged_summary">Viene mostrato un singolo mese. Scorri a destra o a sinistra per cambiare mese.</string>
|
||||||
|
<string name="month_style_continuous">Scorrimento</string>
|
||||||
|
<string name="settings_past_events_hint">Ciò che fa l\'Agenda con eventi conclusi.</string>
|
||||||
|
<string name="settings_dynamic_color_summary">Deduci il colore dell\'app dallo sfondo.</string>
|
||||||
|
<string name="settings_about_privacy">Politica per la Privacy</string>
|
||||||
|
<string name="calendars_visibility_hint">Deseleziona un calendario per non mostrarlo sul dispositivo: scompariranno gli eventi e non riceverai più promemoria. Questa impostazione agisce anche su altre app calendario. Nulla viene eliminato e il calendario non scompare da nessun altro dispositivo. Puoi riselezionarlo in ogni momento.</string>
|
||||||
|
<string name="calendars_visibility_a11y">Mostra \"%1$s\"</string>
|
||||||
|
<string name="calendars_visibility_notice_title">Alcuni calendari non sono attivi</string>
|
||||||
|
<string name="calendars_visibility_notice_message">Calendula mostra solo i calendari che sono attivi sul dispositivo, e alcuni non lo sono. Per vedere gli eventi di quest\'ultimi, attivali dalle Impostazioni.</string>
|
||||||
|
<string name="calendar_picker_missing_title">Manca un calendario?</string>
|
||||||
|
<string name="calendar_picker_missing_summary">Può essere disattivato, in sola lettura o compilato in base ai tuoi contatti. Gestisci qui i tuoi calendari.</string>
|
||||||
|
<string name="calendars_state_read_only">Sola lettura</string>
|
||||||
|
<string name="calendars_state_not_synced">Non sincronizzato su questo dispositivo</string>
|
||||||
|
<string name="calendars_state_managed">Compilato dai tuoi contatti</string>
|
||||||
|
<string name="calendars_managed_delete_locked">Questo calendario è compilato in base ai tuoi contatti, quindi Calendula lo creerà di nuovo alla prossima sincronizzazione. Per eliminarlo disattiva l\'opzione \"Date Speciali\" nelle Impostazioni.</string>
|
||||||
|
<string name="calendars_account_from_source">%1$s (%2$s)</string>
|
||||||
|
<string name="month_style_continuous_summary">Ogni mese è sotto la sua testata, con un piccolo spazio a separarlo dal mese successivo.</string>
|
||||||
|
<string name="month_style_dense">Settimane continue</string>
|
||||||
|
<string name="month_style_dense_summary">Le settimane scorrono senza interruzione, ogni mese succede a quello precedente.</string>
|
||||||
|
<string name="month_style_split">Diviso</string>
|
||||||
|
<string name="month_style_split_summary">Una griglia compatta indica i giorni con eventi. Gli eventi del giorno selezionato sono elencati sotto.</string>
|
||||||
|
<string name="month_split_no_events">Nulla in programma</string>
|
||||||
|
<string name="month_split_expand">Mostra il mese completo</string>
|
||||||
|
<string name="month_split_collapse">Mostra gli eventi del giorno</string>
|
||||||
|
<string name="settings_event_duration">Durata standard</string>
|
||||||
|
<string name="settings_event_duration_hint">Quanto durano gli eventi se non modifichi l\'orario di fine. Non ha effetto sugli eventi giornalieri.</string>
|
||||||
|
<string name="settings_calendar_durations_title">Durata per calendario</string>
|
||||||
|
<string name="settings_calendar_durations_hint">Assegna a ciascun calendario la sua durata standard (e.g. 8 ore per il calendario dei turni lavorativi).</string>
|
||||||
|
<string name="settings_calendar_duration_inherits">Default (%1$s)</string>
|
||||||
|
<string name="settings_calendar_duration_use_default">Usa la durata standard (%1$s)</string>
|
||||||
|
<string name="settings_allday_reminder_fires_at">Promemoria alle %1$s</string>
|
||||||
|
<string name="settings_group_look">Aspetto e Comportamento</string>
|
||||||
|
<string name="settings_group_data">Dati</string>
|
||||||
|
<string name="settings_group_app">App</string>
|
||||||
|
<string name="settings_group_about">A proposito</string>
|
||||||
|
<string name="settings_widgets_subtitle">Widget Agenda e riquadro Impostazioni rapide</string>
|
||||||
|
<string name="settings_backup_subtitle">Export, Import, backup automatici</string>
|
||||||
|
<string name="settings_section_widgets">Widget & Riquardi</string>
|
||||||
|
<string name="settings_widgets_hint">Impostazioni per i widget e il riquardo delle Impostazioni rapide. Aggiungi i widget tenendo premuto sulla home.</string>
|
||||||
|
<string name="settings_section_backup">Backup e ripristino</string>
|
||||||
|
<string name="settings_views_all_header">Tutte le viste</string>
|
||||||
|
<string name="settings_week_day_header">Giorno e settimana</string>
|
||||||
|
<string name="settings_default_view_hint">La vista di default all\'apertura dell\'app.</string>
|
||||||
|
<string name="settings_week_start_hint">Il giorno in cui inizia ogni settimana, in tutta l\'app e i suoi widget.</string>
|
||||||
|
<string name="settings_time_format_hint">Formato degli orari in tutta l\'app. Di default viene seguito il formato di sistema.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
8
app/src/main/res/values-night/themes.xml
Normal file
8
app/src/main/res/values-night/themes.xml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<resources>
|
||||||
|
<!-- Dark backdrop needs light status-bar icons; the base theme's `true` is
|
||||||
|
only right for the light window background. -->
|
||||||
|
<style name="Theme.Calendula" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<item name="android:windowBackground">@color/window_background</item>
|
||||||
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
210
app/src/main/res/values-pt-rBR/strings.xml
Normal file
210
app/src/main/res/values-pt-rBR/strings.xml
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string-array name="backup_interval_units">
|
||||||
|
<item>Minutes</item>
|
||||||
|
<item>Hours</item>
|
||||||
|
<item>Days</item>
|
||||||
|
<item>Weeks</item>
|
||||||
|
</string-array>
|
||||||
|
<string name="state_loading">Carregando…</string>
|
||||||
|
<string name="app_name">Calendula</string>
|
||||||
|
<string name="state_failure_permission">O acesso ao calendário é necessário.</string>
|
||||||
|
<string name="state_failure_no_calendars">Nenhum calendário configurado.</string>
|
||||||
|
<string name="state_failure_no_calendars_action">Abrir configurações do calendário do sistema</string>
|
||||||
|
<string name="state_retry">Tentar novamente</string>
|
||||||
|
<string name="state_failure_permission_action">Conceder acesso</string>
|
||||||
|
<string name="app_tagline">Um calendário moderno.</string>
|
||||||
|
<string name="state_failure_unknown">Algo deu errado.</string>
|
||||||
|
<string name="state_failure_provider">Não foi possível ler o calendário.</string>
|
||||||
|
<string name="permission_rationale_title">Veja todos os seus eventos, lindamente</string>
|
||||||
|
<string name="permission_rationale_body">Calendula precisa acessar seu calendário para mostrar e gerenciar eventos. É tudo que ele pede — e nada sai do seu dispositivo.</string>
|
||||||
|
<string name="permission_request_button">Conceder acesso ao calendário</string>
|
||||||
|
<string name="permission_denied_title">Acesso ao calendário negado</string>
|
||||||
|
<string name="permission_denied_body">Calendula não consegue mostrar eventos sem acesso ao calendário. Você pode conceder novamente nas configurações do sistema.</string>
|
||||||
|
<string name="permission_open_settings_button">Abrir configurações do sistema</string>
|
||||||
|
<string name="permission_retry_button">Tentar novamente</string>
|
||||||
|
<string name="permission_benefit_private_title">Fica no seu dispositivo</string>
|
||||||
|
<string name="permission_benefit_private_body">Seus calendários são lidos localmente e nunca saem do celular.</string>
|
||||||
|
<string name="permission_benefit_sync_title">Todos os seus calendários, juntos</string>
|
||||||
|
<string name="permission_benefit_sync_body">Google, CalDAV, local — tudo sincronizado ao dispositivo apenas aparece.</string>
|
||||||
|
<string name="permission_benefit_privacy_title">Nenhum rastreamento, jamais</string>
|
||||||
|
<string name="permission_benefit_privacy_body">Zero telemetria, zero dados para análise, sem anúncios.</string>
|
||||||
|
<string name="permission_privacy_footnote">Fica no seu dispositivo · sem permissão de internet</string>
|
||||||
|
<string name="month_prev">Mês anterior</string>
|
||||||
|
<string name="month_next">Próximo mês</string>
|
||||||
|
<string name="month_today_action">Hoje</string>
|
||||||
|
<string name="month_more_actions">Mais ações</string>
|
||||||
|
<string name="month_open_menu">Abrir menu</string>
|
||||||
|
<string name="month_action_settings">Configurações</string>
|
||||||
|
<string name="month_a11y_today_prefix">Hoje</string>
|
||||||
|
<string name="week_today_action">Esta semana</string>
|
||||||
|
<string name="week_number_label">Sem</string>
|
||||||
|
<string name="day_today_action">Hoje</string>
|
||||||
|
<string name="event_detail_back">Voltar</string>
|
||||||
|
<string name="event_detail_edit">Editar</string>
|
||||||
|
<string name="event_detail_delete">Excluir</string>
|
||||||
|
<string name="event_detail_share">Compartilhar</string>
|
||||||
|
<string name="event_detail_duplicate">Duplicar</string>
|
||||||
|
<string name="event_share_chooser_title">Compartilhar evento</string>
|
||||||
|
<string name="event_share_failed">Não foi possível compartilhar este evento.</string>
|
||||||
|
<string name="event_delete_title">Excluir evento?</string>
|
||||||
|
<string name="event_delete_body">O evento sairá do calendário e de todos os aparelhos que o sincronizam.</string>
|
||||||
|
<string name="event_delete_recurring_title">Excluir evento recorrente</string>
|
||||||
|
<string name="event_delete_option_occurrence">Apenas este evento</string>
|
||||||
|
<string name="event_delete_option_following">Este evento e todos os próximos</string>
|
||||||
|
<string name="event_delete_option_series">Todos os eventos nesta série</string>
|
||||||
|
<string name="event_edit_recurring_title">Editar evento recorrente</string>
|
||||||
|
<string name="event_delete_failed">Não foi possível excluir o evento</string>
|
||||||
|
<string name="event_delete_write_denied">Calendula requer permissão de escrita para excluir eventos</string>
|
||||||
|
<string name="dialog_cancel">Cancelar</string>
|
||||||
|
<string name="dialog_ok">OK</string>
|
||||||
|
<string name="event_edit_new_title">Novo evento</string>
|
||||||
|
<string name="event_edit_close">Fechar</string>
|
||||||
|
<string name="event_edit_save">Salvar</string>
|
||||||
|
<string name="event_edit_title_hint">Adicionar título</string>
|
||||||
|
<string name="event_edit_managed_hint">Gerenciado por “%1$s” — o título, data e frequência são sincronizados dos seus contatos. Lembretes, locais e notas são seus para editar.</string>
|
||||||
|
<string name="event_edit_starts">Inicia</string>
|
||||||
|
<string name="event_edit_ends">Termina</string>
|
||||||
|
<string name="event_edit_error_end_before_start">Termina antes de iniciar</string>
|
||||||
|
<string name="event_edit_error_no_calendar">Nenhum calendário disponível para escrita</string>
|
||||||
|
<string name="event_edit_save_failed">Não foi possível salvar o evento</string>
|
||||||
|
<string name="event_edit_write_denied">Calendula requer permissão de escrita para criar eventos</string>
|
||||||
|
<string name="event_edit_more_fields">Mais campos</string>
|
||||||
|
<string name="event_edit_add">Adicionar</string>
|
||||||
|
<string name="event_edit_add_reminder">Adicionar lembrete</string>
|
||||||
|
<string name="event_edit_remove_reminder">Remover lembrete</string>
|
||||||
|
<string name="event_edit_attendees">Convidados</string>
|
||||||
|
<string name="event_edit_add_guest">Adicionar convidado</string>
|
||||||
|
<string name="event_edit_add_guest_hint">Adicionar convidado via e-mail…</string>
|
||||||
|
<string name="event_edit_add_guest_from_contacts">Adicionar dos contatos</string>
|
||||||
|
<string name="event_edit_location_from_contacts">Escolher endereço dos contatos</string>
|
||||||
|
<string name="event_edit_remove_guest">Remover convidado</string>
|
||||||
|
<string name="event_edit_attendee_required">Obrigatório</string>
|
||||||
|
<string name="event_edit_attendee_optional">Opcional</string>
|
||||||
|
<string name="event_edit_attendees_note_synced">Calendula não manda convites. Sua conta de calendário poderá enviar e-mails para os convidados quando sincronizar.</string>
|
||||||
|
<string name="event_edit_attendees_note_local">Armazenado neste dispositivo. Ninguém é notificado.</string>
|
||||||
|
<string name="event_edit_reminder_custom">Personalizado</string>
|
||||||
|
<string name="reminder_unit_minutes">minutos</string>
|
||||||
|
<string name="reminder_unit_hours">horas</string>
|
||||||
|
<string name="reminder_unit_days">dias</string>
|
||||||
|
<string name="reminder_unit_weeks">semanas</string>
|
||||||
|
<string name="event_edit_availability">Disponibilidade</string>
|
||||||
|
<string name="event_edit_visibility">Visibilidade</string>
|
||||||
|
<string name="event_edit_timezone_device">Fuso horário do dispositivo</string>
|
||||||
|
<string name="event_edit_timezone_device_summary">Segue onde você estiver</string>
|
||||||
|
<string name="event_edit_timezone_search">Buscar fusos</string>
|
||||||
|
<string name="event_edit_timezone_recent">Recente</string>
|
||||||
|
<string name="event_edit_timezone_all">Todos os fusos</string>
|
||||||
|
<string name="event_edit_timezone_none">Nenhum fuso horário encontrado com “%1$s”</string>
|
||||||
|
<string name="event_edit_timezone_local_time">%1$s seu horário</string>
|
||||||
|
<string name="event_edit_color">Cor</string>
|
||||||
|
<string name="event_edit_color_default">Cor do calendário</string>
|
||||||
|
<string name="event_edit_color_custom">Cor personalizada</string>
|
||||||
|
<string name="event_edit_color_reset">Redefinir</string>
|
||||||
|
<string name="event_edit_color_unsupported">Não disponível neste calendário</string>
|
||||||
|
<string name="event_edit_color_unsupported_hint">Este calendário não publica um esquema de cores. Você pode permitir cores personalizadas nas Configurações.</string>
|
||||||
|
<string name="event_edit_color_sync_warning">Este calendário pode remover ou sobrescrever a cor na próxima sincronização.</string>
|
||||||
|
<string name="event_edit_conflict_title">Evento alterado em outro lugar</string>
|
||||||
|
<string name="event_edit_conflict_body">Enquanto estava editando, este evento foi alterado — por sincronia ou por outro app. O que deve acontecer com suas mudanças?</string>
|
||||||
|
<string name="event_edit_conflict_overwrite">Salvar mudanças</string>
|
||||||
|
<string name="event_edit_conflict_overwrite_hint">Apenas campos editados sobrescrevem a mudança externa</string>
|
||||||
|
<string name="event_edit_conflict_discard">Descartar mudanças</string>
|
||||||
|
<string name="event_edit_conflict_discard_hint">Evento fica como está agora</string>
|
||||||
|
<string name="event_edit_gone_title">Evento excluído</string>
|
||||||
|
<string name="event_edit_gone_body">Este evento foi excluído enquanto isso, por exemplo, em outro aparelho. Suas mudanças não podem mais ser salvas.</string>
|
||||||
|
<string name="import_reminder_prompt_title">Aplicar seu lembrete padrão?</string>
|
||||||
|
<string name="import_reminder_prompt_body_none">Este evento foi importado sem lembretes.</string>
|
||||||
|
<plurals name="import_reminder_prompt_body_existing">
|
||||||
|
<item quantity="one">Este evento foi importado com %1$d lembrete.</item>
|
||||||
|
<item quantity="many">Este evento foi importado com %1$d lembretes.</item>
|
||||||
|
<item quantity="other">Este evento foi importado com %1$d lembretes.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="import_reminder_prompt_apply">Aplicar padrão</string>
|
||||||
|
<string name="import_reminder_prompt_keep">Manter assim</string>
|
||||||
|
<string name="event_edit_recurrence_none">Não se repete</string>
|
||||||
|
<string name="event_edit_recurrence_custom">Personalizado</string>
|
||||||
|
<string name="event_edit_recurrence_every">A cada</string>
|
||||||
|
<string name="recurrence_unit_days">dias</string>
|
||||||
|
<string name="recurrence_unit_weeks">semanas</string>
|
||||||
|
<string name="recurrence_unit_months">meses</string>
|
||||||
|
<string name="recurrence_unit_years">anos</string>
|
||||||
|
<string name="event_edit_recurrence_incomplete">Insira um número de 1 a 999</string>
|
||||||
|
<string name="event_edit_recurrence_next">Próximo: %1$s</string>
|
||||||
|
<string name="event_edit_recurrence_next_none">Esta regra nunca se repete</string>
|
||||||
|
<string name="event_edit_recurrence_ends">Termina</string>
|
||||||
|
<string name="event_edit_recurrence_end_never">Nunca</string>
|
||||||
|
<string name="event_edit_recurrence_end_until">Em uma data</string>
|
||||||
|
<string name="event_edit_recurrence_end_count">Após um número de vezes</string>
|
||||||
|
<string name="event_edit_recurrence_times">vezes</string>
|
||||||
|
<string name="event_edit_error_recurrence_ends_before_start">Repetições terminam antes do evento começar</string>
|
||||||
|
<string name="event_availability_busy">Ocupado</string>
|
||||||
|
<string name="event_access_default">Padrão</string>
|
||||||
|
<string name="event_access_public">Público</string>
|
||||||
|
<string name="event_detail_all_day">O dia todo</string>
|
||||||
|
<string name="event_detail_calendar">Calendário</string>
|
||||||
|
<string name="event_detail_calendar_unknown">Calendário desconhecido</string>
|
||||||
|
<string name="event_detail_location">Local</string>
|
||||||
|
<string name="event_detail_description">Descrição</string>
|
||||||
|
<string name="event_detail_attendees">Participantes</string>
|
||||||
|
<string name="event_detail_recurrence">Recorrência</string>
|
||||||
|
<string name="event_detail_recurring">Evento recorrente</string>
|
||||||
|
<string name="recurrence_daily">Todo dia</string>
|
||||||
|
<string name="recurrence_weekly">Toda semana</string>
|
||||||
|
<string name="recurrence_monthly">Todo mês</string>
|
||||||
|
<string name="recurrence_yearly">Todo ano</string>
|
||||||
|
<string name="recurrence_every_n_days">A cada %1$d dias</string>
|
||||||
|
<string name="recurrence_every_n_weeks">A cada %1$d semanas</string>
|
||||||
|
<string name="recurrence_every_n_months">A cada %1$d meses</string>
|
||||||
|
<string name="recurrence_every_n_years">A cada %1$d anos</string>
|
||||||
|
<string name="recurrence_on_days">%1$s em %2$s</string>
|
||||||
|
<string name="recurrence_with_until">%1$s até %2$s</string>
|
||||||
|
<string name="recurrence_with_count">%1$s, %2$d vezes</string>
|
||||||
|
<string name="event_detail_not_found">Este evento não existe mais.</string>
|
||||||
|
<string name="event_attendee_accepted">Aceitou</string>
|
||||||
|
<string name="event_attendee_declined">Rejeitou</string>
|
||||||
|
<string name="event_attendee_tentative">Não decidiu</string>
|
||||||
|
<string name="event_attendee_needs_action">Sem resposta</string>
|
||||||
|
<string name="event_attendee_unknown">—</string>
|
||||||
|
<string name="event_detail_reminders">Lembretes</string>
|
||||||
|
<string name="event_detail_timezone">Fuso horário</string>
|
||||||
|
<string name="event_status_cancelled">Cancelado</string>
|
||||||
|
<string name="event_availability_free">Disponível</string>
|
||||||
|
<string name="event_access_private">Privado</string>
|
||||||
|
<string name="event_access_confidential">Confidencial</string>
|
||||||
|
<string name="event_access_default_summary">O que este calendário normalmente faz</string>
|
||||||
|
<string name="event_access_public_summary">Todos com acesso veem tudo</string>
|
||||||
|
<string name="event_access_private_summary">Outros veem apenas que está ocupado</string>
|
||||||
|
<string name="event_access_confidential_summary">Marcado como confidencial; o que significa depende da conta</string>
|
||||||
|
<string name="event_attendee_organizer">Organizador</string>
|
||||||
|
<string name="event_attendee_optional">Opcional</string>
|
||||||
|
<string name="event_attendee_resource">Recurso</string>
|
||||||
|
<string name="event_detail_self_response">Sua resposta: %1$s</string>
|
||||||
|
<string name="reminder_at_time">Na hora do evento</string>
|
||||||
|
<string name="reminder_default">Lembrete padrão</string>
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="one">%d minuto antes</item>
|
||||||
|
<item quantity="many">%d minutos antes</item>
|
||||||
|
<item quantity="other">%d minutos antes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="one">%d hora antes</item>
|
||||||
|
<item quantity="many">%d horas antes</item>
|
||||||
|
<item quantity="other">%d horas antes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="one">%d dia antes</item>
|
||||||
|
<item quantity="many">%d dias antes</item>
|
||||||
|
<item quantity="other">%d dias antes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="one">%d semana antes</item>
|
||||||
|
<item quantity="many">%d semanas antes</item>
|
||||||
|
<item quantity="other">%d semanas antes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_minutes">
|
||||||
|
<item quantity="one">%d minuto</item>
|
||||||
|
<item quantity="many">%d minutos</item>
|
||||||
|
<item quantity="other">%d minutos</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="settings_special_dates_grant">Conceder acesso</string>
|
||||||
|
</resources>
|
||||||
@@ -17,14 +17,14 @@
|
|||||||
<string name="state_failure_no_calendars_action">Перейти в системные настройки календаря</string>
|
<string name="state_failure_no_calendars_action">Перейти в системные настройки календаря</string>
|
||||||
<string name="state_failure_provider">Не удалось просмотреть календарь.</string>
|
<string name="state_failure_provider">Не удалось просмотреть календарь.</string>
|
||||||
<string name="permission_rationale_title">Следите за всеми своими событиями. С красотой</string>
|
<string name="permission_rationale_title">Следите за всеми своими событиями. С красотой</string>
|
||||||
<string name="permission_rationale_body">Calendula требуется доступ к вашему календарю, чтобы показывать и управлять вашими событиями. Это всё, что приложение требует с самого начала – и никакая информация не покидает ваш девайс.</string>
|
<string name="permission_rationale_body">Calendula требуется доступ к вашему календарю, чтобы показывать и управлять вашими событиями. Это всё, что приложение требует с самого начала – и никакая информация не покидает ваше устройство.</string>
|
||||||
<string name="permission_request_button">Дать доступ к календарю</string>
|
<string name="permission_request_button">Дать доступ к календарю</string>
|
||||||
<string name="permission_denied_title">В доступе к календарю отказано</string>
|
<string name="permission_denied_title">В доступе к календарю отказано</string>
|
||||||
<string name="permission_denied_body">Calendula не может показать события без доступа к календарю. Предоставьте его снова в настройках телефона.</string>
|
<string name="permission_denied_body">Calendula не может показать события без доступа к календарю. Предоставьте его снова в настройках телефона.</string>
|
||||||
<string name="permission_open_settings_button">Открыть настройки телефона</string>
|
<string name="permission_open_settings_button">Открыть настройки телефона</string>
|
||||||
<string name="permission_retry_button">Попытайтесь снова</string>
|
<string name="permission_retry_button">Попытайтесь снова</string>
|
||||||
<string name="permission_benefit_private_title">Остаётся на вашем устройстве</string>
|
<string name="permission_benefit_private_title">Приватность по умолчанию</string>
|
||||||
<string name="permission_benefit_private_body">Ваши календари просматриваются локально и информация никогда не покидает устройство.</string>
|
<string name="permission_benefit_private_body">Нет доступа к интернету, нету телеметрии — ничто не покидает ваше устройство.</string>
|
||||||
<string name="permission_benefit_sync_title">Все ваши календари. Вместе</string>
|
<string name="permission_benefit_sync_title">Все ваши календари. Вместе</string>
|
||||||
<string name="permission_benefit_sync_body">Google, CalDAV, локальный календарь – всё синхронизированное с устройством просто подключается.</string>
|
<string name="permission_benefit_sync_body">Google, CalDAV, локальный календарь – всё синхронизированное с устройством просто подключается.</string>
|
||||||
<string name="permission_benefit_privacy_title">Никакого отслеживания. Никогда</string>
|
<string name="permission_benefit_privacy_title">Никакого отслеживания. Никогда</string>
|
||||||
@@ -62,4 +62,75 @@
|
|||||||
<string name="event_edit_close">Закрыть</string>
|
<string name="event_edit_close">Закрыть</string>
|
||||||
<string name="event_edit_save">Сохранить</string>
|
<string name="event_edit_save">Сохранить</string>
|
||||||
<string name="event_edit_title_hint">Добавить имя</string>
|
<string name="event_edit_title_hint">Добавить имя</string>
|
||||||
|
<string name="event_move_action">Перенести…</string>
|
||||||
|
<string name="event_move_recurring_title">Перенести повторяющиеся события</string>
|
||||||
|
<string name="event_move_done">Перенесено на %1$s</string>
|
||||||
|
<string name="event_move_undo">Отменить</string>
|
||||||
|
<string name="event_move_occurrence_only">Перемещение всей серии изменит дни на которые она попадёт, поэтому можно переместить только это событие.</string>
|
||||||
|
<string name="event_move_undone">Перемещение отменено</string>
|
||||||
|
<string name="event_move_failed">Не удалось переместить событие</string>
|
||||||
|
<string name="event_move_write_denied">Приложению Calendula требуется доступ на запись, чтобы перемещать события</string>
|
||||||
|
<string name="event_move_gone">Это событие больше не существует</string>
|
||||||
|
<string name="event_move_blocked_series_end">Нельзя переместить событие дальше за пределы его серии</string>
|
||||||
|
<string name="event_edit_managed_hint">Управляется «%1$s» — название, дата и повторение синхронизируются с ваших контактов. Напоминания, местоположения и заметки могут быть редактированы вами.</string>
|
||||||
|
<string name="event_edit_starts">Начинается</string>
|
||||||
|
<string name="event_edit_ends">Заканчивается</string>
|
||||||
|
<string name="event_edit_error_end_before_start">Заканчивается раньше, чем начинается</string>
|
||||||
|
<string name="event_edit_error_no_calendar">Нет календарей с правами на запись</string>
|
||||||
|
<string name="event_edit_save_failed">Не удалось сохранить событие</string>
|
||||||
|
<string name="event_edit_write_denied">Приложению Calendula требуется доступ на запись, чтобы создавать события</string>
|
||||||
|
<string name="event_edit_more_fields">Больше полей</string>
|
||||||
|
<string name="event_edit_add">Добавить</string>
|
||||||
|
<string name="event_edit_add_reminder">Добавить напоминание</string>
|
||||||
|
<string name="event_edit_remove_reminder">Удалить напоминание</string>
|
||||||
|
<string name="event_edit_attendees">Гости</string>
|
||||||
|
<string name="event_edit_add_guest">Добавить гостя</string>
|
||||||
|
<string name="event_edit_add_guest_hint">Добавить гостя по почте…</string>
|
||||||
|
<string name="event_edit_add_guest_from_contacts">Добавить из контактов</string>
|
||||||
|
<string name="event_edit_location_from_contacts">Выбрать адрес из контактов</string>
|
||||||
|
<string name="event_edit_remove_guest">Удалить гостя</string>
|
||||||
|
<string name="event_edit_attendee_required">Обязательный</string>
|
||||||
|
<string name="event_edit_attendee_optional">Необязательный</string>
|
||||||
|
<string name="event_edit_attendees_note_synced">Calendula не отправляет приглашения. Ваша учётная запись календаря может отправить письма гостям при синхронизации.</string>
|
||||||
|
<string name="event_edit_attendees_note_local">Хранится на этом устройстве. Никто не получит уведомление.</string>
|
||||||
|
<string name="event_edit_reminder_custom">Другое</string>
|
||||||
|
<string name="reminder_unit_minutes">минуты</string>
|
||||||
|
<string name="reminder_unit_hours">часы</string>
|
||||||
|
<string name="reminder_unit_days">дни</string>
|
||||||
|
<string name="reminder_unit_weeks">недели</string>
|
||||||
|
<string name="event_edit_availability">Занятость</string>
|
||||||
|
<string name="event_edit_visibility">Видимость</string>
|
||||||
|
<string name="event_edit_timezone_device">Часовой пояс устройства</string>
|
||||||
|
<string name="event_edit_timezone_device_summary">Следует за вами, где бы вы ни были</string>
|
||||||
|
<string name="event_edit_timezone_search">Поиск часовых поясов</string>
|
||||||
|
<string name="event_edit_timezone_recent">Недавние</string>
|
||||||
|
<string name="event_edit_timezone_all">Все часовые пояса</string>
|
||||||
|
<string name="event_edit_timezone_none">Часовые пояса, соответсвующие «%1$s», не найдены</string>
|
||||||
|
<string name="event_edit_timezone_local_time">%1$s по вашему времени</string>
|
||||||
|
<string name="event_edit_color">Цвет</string>
|
||||||
|
<string name="event_edit_color_default">Цвет календаря</string>
|
||||||
|
<string name="event_edit_color_custom">Свой цвет</string>
|
||||||
|
<string name="event_edit_color_reset">Сбросить</string>
|
||||||
|
<string name="event_edit_color_unsupported">Недоступно для этого календаря</string>
|
||||||
|
<string name="event_edit_color_unsupported_hint">Этот календарь не публикует набор цветов. Вы можете разрешить пользовательские цвета для таких календарей в Настройках.</string>
|
||||||
|
<string name="event_edit_color_sync_warning">Этот календарь может потерять или перезаписать цвет при следующей синхронизации.</string>
|
||||||
|
<string name="event_edit_conflict_title">Событие изменено в другом месте</string>
|
||||||
|
<string name="event_edit_conflict_body">Пока вы редактировали, это событие было изменено — синхронизацией или другим приложением. Что вы хотите сделать с вашими изменениями?</string>
|
||||||
|
<string name="event_edit_conflict_overwrite">Сохранить мои изменения</string>
|
||||||
|
<string name="event_edit_conflict_overwrite_hint">Только изменённые вами поля, перезаписывают внешние изменения</string>
|
||||||
|
<string name="event_edit_conflict_discard">Отменить мои изменения</string>
|
||||||
|
<string name="event_edit_conflict_discard_hint">Событие остаётся таким, как сейчас</string>
|
||||||
|
<string name="event_edit_gone_title">Событие удалено</string>
|
||||||
|
<string name="event_edit_gone_body">Это событие было удалено, пока вы его редактировали, например на другом устройстве. Ваши изменения больше не могут быть сохранены.</string>
|
||||||
|
<string name="import_reminder_prompt_title">Применить ваше стандартное напоминание?</string>
|
||||||
|
<string name="import_reminder_prompt_body_none">Это событие было импортировано без каких-либо напоминаний.</string>
|
||||||
|
<plurals name="import_reminder_prompt_body_existing">
|
||||||
|
<item quantity="one">Это событие было импортировано с %1$d напоминанием.</item>
|
||||||
|
<item quantity="few">Это событие было импортировано с %1$d напоминаниями.</item>
|
||||||
|
<item quantity="many">Это событие было импортировано с %1$d напоминаниями.</item>
|
||||||
|
<item quantity="other">Это событие было импортировано с %1$d напоминаниями.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="import_reminder_prompt_apply">Применить стандартное</string>
|
||||||
|
<string name="import_reminder_prompt_keep">Оставить как есть</string>
|
||||||
|
<string name="event_edit_recurrence_none">Не повторяется</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -18,16 +18,20 @@
|
|||||||
|
|
||||||
<!-- Permission flow (F1) -->
|
<!-- Permission flow (F1) -->
|
||||||
<string name="permission_rationale_title">See all your events, beautifully</string>
|
<string name="permission_rationale_title">See all your events, beautifully</string>
|
||||||
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events. That\'s all it asks for up front — and nothing ever leaves your device.</string>
|
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events.</string>
|
||||||
<string name="permission_request_button">Grant calendar access</string>
|
<string name="permission_request_button">Grant calendar access</string>
|
||||||
<string name="permission_denied_title">Calendar access denied</string>
|
<string name="permission_denied_title">Calendar access denied</string>
|
||||||
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
|
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
|
||||||
<string name="permission_open_settings_button">Open system settings</string>
|
<string name="permission_open_settings_button">Open system settings</string>
|
||||||
<string name="permission_retry_button">Try again</string>
|
<string name="permission_retry_button">Try again</string>
|
||||||
<string name="permission_benefit_private_title">Stays on your device</string>
|
<string name="permission_benefit_private_title">Private by design</string>
|
||||||
<string name="permission_benefit_private_body">Your calendars are read locally and never leave the phone.</string>
|
<string name="permission_benefit_private_body">No internet permission, no telemetry — nothing ever leaves your phone.</string>
|
||||||
<string name="permission_benefit_sync_title">All your calendars, together</string>
|
<string name="permission_benefit_sync_title">All your calendars, together</string>
|
||||||
<string name="permission_benefit_sync_body">Google, CalDAV, local — anything synced to the device just appears.</string>
|
<string name="permission_benefit_sync_body">Google, CalDAV, local — anything synced to the device just appears.</string>
|
||||||
|
<!-- Unused since the wizard trim (#163) — the privacy point is now
|
||||||
|
permission_benefit_private_*. Kept until Weblate drops them from the
|
||||||
|
translations: removing a base key a translation still carries is a
|
||||||
|
fatal ExtraTranslation. -->
|
||||||
<string name="permission_benefit_privacy_title">No tracking, ever</string>
|
<string name="permission_benefit_privacy_title">No tracking, ever</string>
|
||||||
<string name="permission_benefit_privacy_body">Zero telemetry, zero analytics, no ads.</string>
|
<string name="permission_benefit_privacy_body">Zero telemetry, zero analytics, no ads.</string>
|
||||||
<string name="permission_privacy_footnote">Stays on your device · no internet permission</string>
|
<string name="permission_privacy_footnote">Stays on your device · no internet permission</string>
|
||||||
@@ -65,6 +69,20 @@
|
|||||||
<string name="event_edit_recurring_title">Edit recurring event</string>
|
<string name="event_edit_recurring_title">Edit recurring event</string>
|
||||||
<string name="event_delete_failed">Couldn\'t delete the event</string>
|
<string name="event_delete_failed">Couldn\'t delete the event</string>
|
||||||
<string name="event_delete_write_denied">Calendula needs write access to delete events</string>
|
<string name="event_delete_write_denied">Calendula needs write access to delete events</string>
|
||||||
|
|
||||||
|
<!-- Drag an event to another time or day (#68) -->
|
||||||
|
<string name="event_move_action">Move…</string>
|
||||||
|
<string name="event_move_recurring_title">Move recurring event</string>
|
||||||
|
<string name="event_move_occurrence_only">Moving the whole series would change which days it falls on, so only this event can move.</string>
|
||||||
|
<!-- %1$s is the new date and time, e.g. "Fri, 7 Aug, 09:00". -->
|
||||||
|
<string name="event_move_done">Moved to %1$s</string>
|
||||||
|
<string name="event_move_undo">Undo</string>
|
||||||
|
<string name="event_move_undone">Move undone</string>
|
||||||
|
<string name="event_move_failed">Couldn\'t move the event</string>
|
||||||
|
<string name="event_move_write_denied">Calendula needs write access to move events</string>
|
||||||
|
<string name="event_move_gone">That event no longer exists</string>
|
||||||
|
<string name="event_move_blocked_series_end">Can\'t move an event past the end of its series</string>
|
||||||
|
|
||||||
<string name="dialog_cancel">Cancel</string>
|
<string name="dialog_cancel">Cancel</string>
|
||||||
<string name="dialog_ok">OK</string>
|
<string name="dialog_ok">OK</string>
|
||||||
|
|
||||||
@@ -242,6 +260,10 @@
|
|||||||
<item quantity="one">%d day</item>
|
<item quantity="one">%d day</item>
|
||||||
<item quantity="other">%d days</item>
|
<item quantity="other">%d days</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<!-- A mixed duration: %1$s is the hours part, %2$s the minutes part. -->
|
||||||
|
<string name="duration_hours_minutes">%1$s %2$s</string>
|
||||||
|
<!-- Shown instead of the preview when a custom length exceeds what the setting keeps. %1$s is the longest allowed, e.g. "24 hours". -->
|
||||||
|
<string name="duration_custom_max">At most %1$s</string>
|
||||||
<plurals name="duration_weeks">
|
<plurals name="duration_weeks">
|
||||||
<item quantity="one">%d week</item>
|
<item quantity="one">%d week</item>
|
||||||
<item quantity="other">%d weeks</item>
|
<item quantity="other">%d weeks</item>
|
||||||
@@ -250,6 +272,25 @@
|
|||||||
<!-- Shared event strings -->
|
<!-- Shared event strings -->
|
||||||
<string name="event_untitled">(No title)</string>
|
<string name="event_untitled">(No title)</string>
|
||||||
|
|
||||||
|
<!-- First-launch wizard (#163) -->
|
||||||
|
<string name="onboarding_step_counter">Step %1$d of %2$d</string>
|
||||||
|
<string name="onboarding_backup_title">Your events live only here</string>
|
||||||
|
<string name="onboarding_backup_body">Nothing on this phone syncs to an account, so losing it would lose your calendar. Calendula can write a backup for you.</string>
|
||||||
|
<string name="onboarding_backup_benefit_folder_title">A folder you choose</string>
|
||||||
|
<string name="onboarding_backup_benefit_folder_body">Backups are plain .ics files — put them somewhere that syncs, or on an SD card.</string>
|
||||||
|
<string name="onboarding_backup_benefit_daily_title">Once a day, by itself</string>
|
||||||
|
<string name="onboarding_backup_benefit_daily_body">Calendula exports your local calendars in the background. Change how often in Settings.</string>
|
||||||
|
<string name="onboarding_backup_enable_button">Choose folder and back up</string>
|
||||||
|
<string name="onboarding_backup_skip_button">Not now</string>
|
||||||
|
<string name="onboarding_view_title">What should open first?</string>
|
||||||
|
<string name="onboarding_month_style_title">How should Month look?</string>
|
||||||
|
<string name="onboarding_view_continue_button">Continue</string>
|
||||||
|
<string name="onboarding_back">Back</string>
|
||||||
|
<string name="onboarding_visibility_button">Got it</string>
|
||||||
|
<string name="onboarding_done_title">You\'re all set</string>
|
||||||
|
<string name="onboarding_done_body">Your calendars are ready. Everything you just chose can be changed later in Settings.</string>
|
||||||
|
<string name="onboarding_done_button">Open my calendar</string>
|
||||||
|
|
||||||
<!-- Reminder notifications (v1.4) -->
|
<!-- Reminder notifications (v1.4) -->
|
||||||
<string name="reminder_channel_name">Event reminders</string>
|
<string name="reminder_channel_name">Event reminders</string>
|
||||||
<string name="reminder_channel_description">Notifications at the reminder times of your events</string>
|
<string name="reminder_channel_description">Notifications at the reminder times of your events</string>
|
||||||
@@ -259,6 +300,7 @@
|
|||||||
<string name="reminder_benefit_delivery_body">Every reminder on your events arrives as a notification, right on time.</string>
|
<string name="reminder_benefit_delivery_body">Every reminder on your events arrives as a notification, right on time.</string>
|
||||||
<string name="reminder_benefit_duplicates_title">Using a second calendar app?</string>
|
<string name="reminder_benefit_duplicates_title">Using a second calendar app?</string>
|
||||||
<string name="reminder_benefit_duplicates_body">If another app also posts reminders, you\'ll see them twice — turn them off there or here.</string>
|
<string name="reminder_benefit_duplicates_body">If another app also posts reminders, you\'ll see them twice — turn them off there or here.</string>
|
||||||
|
<!-- Unused since the wizard trim (#163); see the note above. -->
|
||||||
<string name="reminder_benefit_reversible_title">Change it anytime</string>
|
<string name="reminder_benefit_reversible_title">Change it anytime</string>
|
||||||
<string name="reminder_benefit_reversible_body">The switch lives in Settings, under Notifications.</string>
|
<string name="reminder_benefit_reversible_body">The switch lives in Settings, under Notifications.</string>
|
||||||
<string name="reminder_onboarding_enable_button">Turn on reminders</string>
|
<string name="reminder_onboarding_enable_button">Turn on reminders</string>
|
||||||
@@ -300,6 +342,26 @@
|
|||||||
<string name="search_idle_hint">Search your events by title, location or notes.</string>
|
<string name="search_idle_hint">Search your events by title, location or notes.</string>
|
||||||
<string name="search_empty">No events match “%1$s”.</string>
|
<string name="search_empty">No events match “%1$s”.</string>
|
||||||
|
|
||||||
|
<!-- Selecting search results to delete several at once (#80) -->
|
||||||
|
<plurals name="search_selected_count">
|
||||||
|
<item quantity="one">%d selected</item>
|
||||||
|
<item quantity="other">%d selected</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="search_in_descriptions">Found in descriptions</string>
|
||||||
|
<string name="search_selection_close">Cancel selection</string>
|
||||||
|
<string name="search_select_all">Select all</string>
|
||||||
|
<string name="search_delete_selected">Delete selected</string>
|
||||||
|
<plurals name="search_delete_title">
|
||||||
|
<item quantity="one">Delete %d event?</item>
|
||||||
|
<item quantity="other">Delete %d events?</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="search_delete_done">
|
||||||
|
<item quantity="one">%d event deleted</item>
|
||||||
|
<item quantity="other">%d events deleted</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="search_delete_partial">%1$d deleted, %2$d couldn\'t be</string>
|
||||||
|
<string name="search_delete_denied_partial">%1$d deleted, then write access was withdrawn</string>
|
||||||
|
|
||||||
<!-- Home-screen widgets -->
|
<!-- Home-screen widgets -->
|
||||||
<string name="widget_agenda_title">Upcoming</string>
|
<string name="widget_agenda_title">Upcoming</string>
|
||||||
<string name="widget_agenda_label">Calendula agenda</string>
|
<string name="widget_agenda_label">Calendula agenda</string>
|
||||||
@@ -330,8 +392,8 @@
|
|||||||
<string name="settings_default_view">Default view</string>
|
<string name="settings_default_view">Default view</string>
|
||||||
<string name="settings_dynamic_color">Dynamic colour</string>
|
<string name="settings_dynamic_color">Dynamic colour</string>
|
||||||
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
||||||
<string name="settings_soften_colors">Soften calendar colours</string>
|
<string name="settings_soften_colors">Harmonise calendar colours</string>
|
||||||
<string name="settings_soften_colors_summary">Tone calendar and event colours down to fit the theme. Turn off to show the raw colours from the calendar source.</string>
|
<string name="settings_soften_colors_summary">Keep each calendar\'s hue but even out its brightness, so every event stays legible and the colours sit together. Turn off to show the raw colours from the calendar source.</string>
|
||||||
<string name="settings_font_headings">Headings font</string>
|
<string name="settings_font_headings">Headings font</string>
|
||||||
<string name="settings_font_body">Body font</string>
|
<string name="settings_font_body">Body font</string>
|
||||||
<string name="settings_font_system">System default</string>
|
<string name="settings_font_system">System default</string>
|
||||||
@@ -363,8 +425,22 @@
|
|||||||
<string name="settings_time_format_auto_summary">Following the system: %1$s</string>
|
<string name="settings_time_format_auto_summary">Following the system: %1$s</string>
|
||||||
<string name="settings_hour_lines">Hour lines</string>
|
<string name="settings_hour_lines">Hour lines</string>
|
||||||
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
|
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
|
||||||
|
<string name="settings_timeline_scale">Hour height</string>
|
||||||
|
<string name="settings_timeline_scale_hint">How much vertical space one hour takes in week and day view. Both views share this setting. You can also pinch the timeline with two fingers to set any height in between.</string>
|
||||||
|
<string name="timeline_scale_fit_day">Fit whole day</string>
|
||||||
|
<string name="timeline_scale_fit_day_summary">All 24 hours on one screen, no scrolling</string>
|
||||||
|
<string name="timeline_scale_compact">Compact</string>
|
||||||
|
<string name="timeline_scale_compact_summary">More hours per screen, smaller blocks</string>
|
||||||
|
<string name="timeline_scale_regular">Regular</string>
|
||||||
|
<string name="timeline_scale_regular_summary">The standard spacing</string>
|
||||||
|
<string name="timeline_scale_comfortable">Comfortable</string>
|
||||||
|
<string name="timeline_scale_comfortable_summary">Roomier blocks, more scrolling</string>
|
||||||
|
<string name="timeline_scale_custom">Custom</string>
|
||||||
|
<string name="timeline_scale_custom_summary">The height you pinched the timeline to</string>
|
||||||
<string name="settings_dim_completed">Dim completed events</string>
|
<string name="settings_dim_completed">Dim completed events</string>
|
||||||
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
|
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
|
||||||
|
<string name="settings_drag_to_reschedule">Drag to reschedule</string>
|
||||||
|
<string name="settings_drag_to_reschedule_summary">Move an event by dragging it to another day or time</string>
|
||||||
<string name="settings_past_events">Past events</string>
|
<string name="settings_past_events">Past events</string>
|
||||||
<string name="settings_past_events_show">Show</string>
|
<string name="settings_past_events_show">Show</string>
|
||||||
<string name="settings_past_events_dim">Dim</string>
|
<string name="settings_past_events_dim">Dim</string>
|
||||||
@@ -428,6 +504,14 @@
|
|||||||
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
||||||
<string name="settings_autofocus_title">Focus title on new event</string>
|
<string name="settings_autofocus_title">Focus title on new event</string>
|
||||||
<string name="settings_autofocus_title_hint">When you start a new event, place the cursor in the title field and open the keyboard right away.</string>
|
<string name="settings_autofocus_title_hint">When you start a new event, place the cursor in the title field and open the keyboard right away.</string>
|
||||||
|
<string name="settings_event_duration">Default duration</string>
|
||||||
|
<string name="settings_event_duration_hint">How long a new event lasts until you change its end time. All-day events aren\'t affected.</string>
|
||||||
|
<string name="settings_calendar_durations_title">Per-calendar duration</string>
|
||||||
|
<string name="settings_calendar_durations_hint">Give a calendar its own default length — e.g. 8 hours for work shifts.</string>
|
||||||
|
<!-- Row summary for a calendar with no duration of its own. %1$s is the default length, e.g. "1 hour". -->
|
||||||
|
<string name="settings_calendar_duration_inherits">Default (%1$s)</string>
|
||||||
|
<!-- Picker row that drops a calendar\'s own length. %1$s is the default length. -->
|
||||||
|
<string name="settings_calendar_duration_use_default">Use default duration (%1$s)</string>
|
||||||
<string name="settings_color_unsupported">Allow colors on unsupported calendars</string>
|
<string name="settings_color_unsupported">Allow colors on unsupported calendars</string>
|
||||||
<string name="settings_color_unsupported_hint">Some calendars (e.g. certain CalDAV) publish no color set; a custom event color may be dropped or overwritten on their next sync. That\'s a limitation of those calendars, not something Calendula can fix.</string>
|
<string name="settings_color_unsupported_hint">Some calendars (e.g. certain CalDAV) publish no color set; a custom event color may be dropped or overwritten on their next sync. That\'s a limitation of those calendars, not something Calendula can fix.</string>
|
||||||
<string name="settings_section_notifications">Notifications</string>
|
<string name="settings_section_notifications">Notifications</string>
|
||||||
@@ -539,7 +623,7 @@
|
|||||||
<string name="calendars_visibility_hint">Turn a calendar off to hide it on this device — its events disappear from the app and it stops reminding you. This is the same switch your other calendar apps use, so they hide it too. Nothing is deleted, no other device is affected, and you can turn it back on here anytime.</string>
|
<string name="calendars_visibility_hint">Turn a calendar off to hide it on this device — its events disappear from the app and it stops reminding you. This is the same switch your other calendar apps use, so they hide it too. Nothing is deleted, no other device is affected, and you can turn it back on here anytime.</string>
|
||||||
<string name="calendars_visibility_a11y">Show \"%1$s\"</string>
|
<string name="calendars_visibility_a11y">Show \"%1$s\"</string>
|
||||||
<string name="calendars_visibility_notice_title">Some calendars are switched off</string>
|
<string name="calendars_visibility_notice_title">Some calendars are switched off</string>
|
||||||
<string name="calendars_visibility_notice_message">Calendula now shows the calendars that are switched on for this device, so what you see and what reminds you can no longer disagree. Some of yours are currently off — they were switched off here or in another calendar app. Turn any of them back on in Settings → Calendars.</string>
|
<string name="calendars_visibility_notice_message">Calendula shows the calendars that are switched on for this device, and some of yours are off. To see their events, switch them on under Settings → Calendars.</string>
|
||||||
<!-- Footer row under the event-form and .ics import calendar pickers. -->
|
<!-- Footer row under the event-form and .ics import calendar pickers. -->
|
||||||
<string name="calendar_picker_missing_title">Missing a calendar?</string>
|
<string name="calendar_picker_missing_title">Missing a calendar?</string>
|
||||||
<string name="calendar_picker_missing_summary">It may be switched off, read-only, or filled from your contacts — manage your calendars here.</string>
|
<string name="calendar_picker_missing_summary">It may be switched off, read-only, or filled from your contacts — manage your calendars here.</string>
|
||||||
|
|||||||
@@ -5,8 +5,6 @@
|
|||||||
Compose theme; this is essentially the launch/backdrop theme. -->
|
Compose theme; this is essentially the launch/backdrop theme. -->
|
||||||
<style name="Theme.Calendula" parent="Theme.AppCompat.DayNight.NoActionBar">
|
<style name="Theme.Calendula" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<item name="android:windowBackground">@color/window_background</item>
|
<item name="android:windowBackground">@color/window_background</item>
|
||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
|
||||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
|
||||||
<item name="android:windowLightStatusBar">true</item>
|
<item name="android:windowLightStatusBar">true</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -15,5 +15,6 @@
|
|||||||
<locale android:name="fr" />
|
<locale android:name="fr" />
|
||||||
<locale android:name="it" />
|
<locale android:name="it" />
|
||||||
<locale android:name="pl" />
|
<locale android:name="pl" />
|
||||||
|
<locale android:name="pt-BR" />
|
||||||
<locale android:name="zh-CN" />
|
<locale android:name="zh-CN" />
|
||||||
</locale-config>
|
</locale-config>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
|
|||||||
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.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -451,15 +452,15 @@ class CalendarRepositoryImplTest {
|
|||||||
calendarsResult = listOf(makeCal(1L), makeCal(2L, visible = false), makeCal(3L))
|
calendarsResult = listOf(makeCal(1L), makeCal(2L, visible = false), makeCal(3L))
|
||||||
searchResult = {
|
searchResult = {
|
||||||
listOf(
|
listOf(
|
||||||
makeEvent(10L, "Shown", calendarId = 1L),
|
SearchCandidate(makeEvent(10L, "Shown", calendarId = 1L)),
|
||||||
makeEvent(11L, "Switched off", calendarId = 2L),
|
SearchCandidate(makeEvent(11L, "Switched off", calendarId = 2L)),
|
||||||
makeEvent(12L, "Hidden", calendarId = 3L),
|
SearchCandidate(makeEvent(12L, "Hidden", calendarId = 3L)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), Dispatchers.Unconfined)
|
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
assertThat(repo.searchEvents("e").map { it.title }).containsExactly("Shown")
|
assertThat(repo.searchEvents("e").map { it.event.title }).containsExactly("Shown")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user