Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d387f3d7bf | ||
|
|
3bead4a21f | ||
|
|
422904c9b9 | ||
|
|
51b9c56c3c | ||
|
|
2af0f44fbe | ||
|
|
7492888b1a | ||
|
|
17223cf0bb | ||
|
|
21c770afb3 | ||
|
|
aee23dc751 | ||
|
|
705835a66f | ||
|
|
c2302c033e | ||
|
|
596f76bbaa | ||
|
|
85f77f2332 | ||
|
|
d471255340 | ||
|
|
dcb1e891f1 | ||
|
|
7bf41fa0f8 | ||
|
|
a23ea81c48 | ||
|
|
52aa48fda7 | ||
|
|
264b1ebbf4 | ||
|
|
bd04f7692c | ||
|
|
26de9d369d | ||
|
|
27e5b41038 | ||
|
|
2119a36969 | ||
|
|
8d3081a086 | ||
|
|
d1664b28de | ||
|
|
252689038e | ||
|
|
406642050e | ||
|
|
ddf3e203a0 | ||
|
|
e8ed4c30e1 | ||
|
|
d7315caa20 | ||
|
|
62ea5ce9d3 | ||
|
|
254fafe42b | ||
|
|
f9dd7a22b7 | ||
|
|
7d7d16ca31 | ||
|
|
6ed0b30230 | ||
|
|
8d3d28c240 | ||
|
|
f9738b0c23 | ||
|
|
39ca7d869a | ||
|
|
0c36ed2a75 | ||
|
|
e84378cc5b | ||
|
|
57dcda5b54 | ||
|
|
df33eca5f2 | ||
|
|
dc841a91e0 | ||
|
|
f7805cb0b5 | ||
|
|
33c61202d9 | ||
|
|
9f05a53ed2 | ||
|
|
a2b6793406 | ||
|
|
eda616b42e | ||
|
|
7c17bf2ff4 | ||
|
|
9ca10fc6ee | ||
|
|
994fa010b8 | ||
|
|
5c20c5f6eb | ||
|
|
c4bb7ae0b2 | ||
|
|
0c985a280c | ||
|
|
17ebb9fd42 | ||
|
|
cadc2b14db | ||
|
|
1fdca903be | ||
|
|
5e7658aa80 | ||
|
|
bea31032fb | ||
|
|
f7c1dbdbc4 | ||
|
|
a1ed010963 | ||
|
|
7be6b7ac31 | ||
|
|
20c20b71ea | ||
|
|
0fb3df0b3a | ||
|
|
e10b09a9b5 | ||
|
|
806a576380 | ||
|
|
3c365c6792 | ||
|
|
4fb4a85ac4 | ||
|
|
e7312e5d6e | ||
|
|
12c2de7ed8 | ||
|
|
8a3cc95c66 | ||
|
|
9c06ffdb48 | ||
|
|
870a903015 | ||
|
|
b5768e3b17 | ||
|
|
02b33dbf07 | ||
|
|
a0c0b875f0 | ||
|
|
512bf8c746 | ||
|
|
04257e8a66 | ||
|
|
c04b2ecee2 |
+133
@@ -5,6 +5,122 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Pages can open the split agenda.** Swipe up from a full month to reveal the compact grid and month agenda; tap a day to filter it, then swipe down to return. The transition gives haptic feedback ([#111]).
|
||||
- **Week numbers open their week.** Tap a week number in the month grid to open Week at that row; Back returns to Month ([#197]).
|
||||
|
||||
### Changed
|
||||
- **Split view opens with the month's agenda.** It starts at today in the current month; tapping a day filters the list, and the filter chip restores the full month. Swiping the list moves by day when filtered and by month otherwise ([#142]).
|
||||
- **Today stands out in the month grid.** Its whole cell has a distinct background; tapping Today briefly expands that cell and nudges its neighbours aside ([#241]).
|
||||
|
||||
### Fixed
|
||||
- **Multi-day events stay above shorter events.** Month and Week bars, Split day lists, and Agenda now place multi-day events before one-day all-day events, then timed events; declined events remain last ([#291]).
|
||||
|
||||
## [2.21.0] — 2026-09-23
|
||||
|
||||
### Added
|
||||
- **Setup makes sure you have a calendar to write to.** A fresh install could
|
||||
finish onboarding and land on an empty grid with nothing saying why — either
|
||||
because the device had no calendar at all, or because every calendar it had
|
||||
was switched off. Setup now covers both: it offers to create a calendar on
|
||||
this device or to add an account that syncs one, and it lists the calendars
|
||||
that exist but are switched off so you can turn them back on ([#287]).
|
||||
|
||||
### Changed
|
||||
- **The month grid gives its days more room.** Week numbers are set smaller and
|
||||
their gutter is measured from the widest number it has to seat instead of
|
||||
holding a fixed 40dp; event chips spend less of their width on padding; and
|
||||
the labels in both are tracked more tightly, which is worth roughly a
|
||||
character per chip ([#189], [#190], [#212], [#213]).
|
||||
- **A month day shows as many events as it has height for.** The limit was a
|
||||
flat three at every size, so a tall week row threw away space it could have
|
||||
filled, while a cramped one drew a third chip its band could not hold and put
|
||||
the "more" marker below the clip, where nothing showed the day held more at
|
||||
all. It is measured per week row now, and a tablet or a landscape phone seats
|
||||
more of them.
|
||||
- **Split view no longer pads a short month out to six rows.** A five-row month
|
||||
was handing the day pane a blank row and its gap — a sixth of the grid. The
|
||||
grid now draws only the rows its month spans and the pane grows into the rest
|
||||
([#162]).
|
||||
|
||||
### Fixed
|
||||
- **Short events show their title in week and day view.** A block needed 20dp
|
||||
of height before it drew any title at all, which at fit-the-whole-day put the
|
||||
cutoff around the half hour. The padding now tapers as the block shrinks, so
|
||||
a short event keeps its name ([#289]).
|
||||
- **A fully zoomed-out timeline sits still.** Rounding its height up left it as
|
||||
much as a pixel per hour taller than the viewport those hours are meant to
|
||||
fill — some 24px of leftover scroll, enough to bounce off Android's
|
||||
overscroll stretch ([#290]).
|
||||
- **Backup & restore says what is going on instead of coming up blank.** It
|
||||
built its rows straight from the calendar list, so loading, a provider error,
|
||||
and a device with nothing to export all rendered as an empty page with no
|
||||
buttons on it ([#304]).
|
||||
- **An event with no title reads in your language.** It was labelled "(Ohne
|
||||
Titel)" on every screen but the event's own, whatever language the phone was
|
||||
set to: the German text was chosen before the translated one could be
|
||||
([#321]).
|
||||
- **A calendar the system gives no name reads in your language.** The same
|
||||
cause, in the calendar lists and pickers — and because the calendar editor
|
||||
starts from that name, saving such a calendar wrote the German text into it
|
||||
for real ([#329]).
|
||||
|
||||
## [2.20.4] — 2026-09-20
|
||||
|
||||
### Fixed
|
||||
- **The app no longer reopens the event you last came in on.** Opening an event
|
||||
from a reminder or a home-screen widget made the app return to that same
|
||||
event on every later launch — and show an error screen once the event had
|
||||
been deleted. Android hands an app the launch it was started with again each
|
||||
time it is rebuilt, and Calendula was acting on it every time instead of
|
||||
once ([#309]).
|
||||
- **"Today" works in the agenda again.** Scrolling a few days ahead and tapping
|
||||
today did nothing, because the agenda is browsed by scrolling rather than by
|
||||
moving its date, and the button only moved the date. It now brings the list
|
||||
back to today, and it stays offered while the list is scrolled away — before,
|
||||
it was hidden in exactly the situation it is for ([#305]).
|
||||
- **Calendula is filed under "Calendar & Agenda" in the self-hosted repo.**
|
||||
Browsing by category in an F-Droid client set to prefer that repo listed the
|
||||
app under "Time" instead ([#294]).
|
||||
|
||||
## [2.20.3] — 2026-09-12
|
||||
|
||||
### Fixed
|
||||
- **A custom agenda range crashed the app in French.** Choosing a custom range
|
||||
for the agenda — or simply typing a number into that field — closed the app on
|
||||
any phone set to French. The French translation of the "%d days" label was
|
||||
missing the wording French uses for counts of two and above, and Android has
|
||||
nothing to fall back on when that particular form is absent, so every value
|
||||
except one day was affected ([#297], [#298]).
|
||||
|
||||
## [2.20.2] — 2026-09-12
|
||||
|
||||
### Fixed
|
||||
- **Crash reports are filed in English.** Reporting a crash from a phone set to
|
||||
another language prefilled the issue — title and body — in that language, so
|
||||
a French crash arrived on Codeberg as a French issue ([#297], [#298]). The
|
||||
text that is *posted* is now English for everyone; the dialog that asks you
|
||||
to send it, and everything else you see in the app, is still translated.
|
||||
|
||||
## [2.20.1] — 2026-09-07
|
||||
|
||||
### Fixed
|
||||
- **An empty calendar now explains itself.** "No calendars configured" offered
|
||||
an "Open system calendar settings" button that opened nothing — every screen
|
||||
wired it to the same reload that had just produced the empty state. It now
|
||||
opens the system's account chooser, filtered to the providers that sync
|
||||
calendars ([#239]).
|
||||
- **Calendars that are all switched off no longer look like an empty month.**
|
||||
When every calendar on the device is hidden at system level there is nothing
|
||||
to draw, but the app said nothing at all: it only ever checked whether the
|
||||
device had *no* calendars, so a device that had them and hid them fell
|
||||
through to a blank grid with no hint. That case now says so, and points at
|
||||
Settings → Calendars — the one screen that can switch a calendar back on. The
|
||||
navigation drawer's calendar list drew the same conflation and is fixed with
|
||||
it ([#239]).
|
||||
|
||||
## [2.20.0] — 2026-09-07
|
||||
|
||||
### Added
|
||||
@@ -1654,3 +1770,20 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#150]: https://codeberg.org/jlmakiola/calendula/issues/150
|
||||
[#146]: https://codeberg.org/jlmakiola/calendula/issues/146
|
||||
[#267]: https://codeberg.org/jlmakiola/calendula/issues/267
|
||||
[#239]: https://codeberg.org/jlmakiola/calendula/issues/239
|
||||
[#297]: https://codeberg.org/jlmakiola/calendula/issues/297
|
||||
[#298]: https://codeberg.org/jlmakiola/calendula/issues/298
|
||||
[#309]: https://codeberg.org/jlmakiola/calendula/issues/309
|
||||
[#305]: https://codeberg.org/jlmakiola/calendula/issues/305
|
||||
[#294]: https://codeberg.org/jlmakiola/calendula/issues/294
|
||||
[#287]: https://codeberg.org/jlmakiola/calendula/issues/287
|
||||
[#189]: https://codeberg.org/jlmakiola/calendula/issues/189
|
||||
[#190]: https://codeberg.org/jlmakiola/calendula/issues/190
|
||||
[#212]: https://codeberg.org/jlmakiola/calendula/issues/212
|
||||
[#213]: https://codeberg.org/jlmakiola/calendula/issues/213
|
||||
[#162]: https://codeberg.org/jlmakiola/calendula/issues/162
|
||||
[#289]: https://codeberg.org/jlmakiola/calendula/issues/289
|
||||
[#290]: https://codeberg.org/jlmakiola/calendula/issues/290
|
||||
[#304]: https://codeberg.org/jlmakiola/calendula/issues/304
|
||||
[#321]: https://codeberg.org/jlmakiola/calendula/issues/321
|
||||
[#329]: https://codeberg.org/jlmakiola/calendula/issues/329
|
||||
|
||||
@@ -157,6 +157,8 @@ reinstalling. Google Play is the exception — see above.</sub>
|
||||
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
|
||||
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
|
||||
- **[Milestones](https://codeberg.org/jlmakiola/calendula/milestones)** — what's shipped and what's next
|
||||
- **[Privacy policy](docs/PRIVACY.md)** — the source of the page published at
|
||||
[jeanlucmakiola.de/calendula/privacy](https://jeanlucmakiola.de/calendula/privacy)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
@@ -187,6 +189,16 @@ No coding needed — register on the Weblate server, pick (or request) a languag
|
||||
and translate the strings in your browser. You can also reach this link in the
|
||||
app from the top of **Settings → App language**.
|
||||
|
||||
## 🔒 Privacy
|
||||
|
||||
No analytics, no tracking, no advertising, no crash-reporting SDK, no third-party
|
||||
services — and **no internet permission at all**, so the app is technically
|
||||
incapable of transmitting anything anywhere. Your events stay in Android's own
|
||||
calendar store, where they already were.
|
||||
|
||||
**→ [Privacy policy](https://jeanlucmakiola.de/calendula/privacy)** — written in
|
||||
[`docs/PRIVACY.md`](docs/PRIVACY.md), which is what that page renders.
|
||||
|
||||
## 📜 License
|
||||
|
||||
[MIT](LICENSE) — Jean-Luc Makiola, 2026
|
||||
|
||||
+12
-8
@@ -28,8 +28,8 @@ android {
|
||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||
versionCode = 22000
|
||||
versionName = "2.20.0"
|
||||
versionCode = 22100
|
||||
versionName = "2.21.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -113,12 +113,16 @@ android {
|
||||
lint {
|
||||
// Community translations are expected to be partial — a missing string
|
||||
// falls back to the English base at runtime — so don't fail the build on
|
||||
// it. Likewise a translated <plurals> may not fill every CLDR quantity
|
||||
// form its locale defines (e.g. Arabic needs "zero"); the missing form
|
||||
// falls back to "other" at runtime, so MissingQuantity is informational
|
||||
// too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
|
||||
// check_translations.py guards the same invariants with clearer,
|
||||
// translator-facing messages.
|
||||
// it. A translated <plurals> may likewise skip a CLDR quantity form its
|
||||
// locale defines (e.g. Arabic "zero"): Android falls back to "other" for
|
||||
// any form it cannot find, so MissingQuantity is informational too.
|
||||
// What a translation must NOT skip is "other" itself — nothing falls back
|
||||
// for that one, not even the base locale, so it throws
|
||||
// Resources$NotFoundException at runtime (Codeberg #297/#298).
|
||||
// MissingQuantity doesn't tell the two cases apart, so that invariant is
|
||||
// enforced by scripts/check_translations.py instead, with a clearer
|
||||
// translator-facing message. Stale/extra keys (ExtraTranslation) stay
|
||||
// fatal.
|
||||
informational += listOf("MissingTranslation", "MissingQuantity")
|
||||
}
|
||||
|
||||
|
||||
@@ -32,13 +32,15 @@ class CalendulaApp : Application() {
|
||||
// respecting, on-device; the user submits the report by hand). The
|
||||
// capture/loop-detection/report machinery lives in floret-kit's
|
||||
// core-crash; only the app label + issue-tracker URLs are app-specific.
|
||||
// The prefilled issue's own title and body are the kit's, in English:
|
||||
// they are posted to the tracker, so they don't follow the device
|
||||
// language the way the dialog around them does.
|
||||
CrashReporter.install(
|
||||
this,
|
||||
CrashConfig(
|
||||
appLabel = getString(R.string.app_name),
|
||||
newIssueUrl = getString(R.string.report_issue_url),
|
||||
chooseIssueUrl = getString(R.string.report_issue_choose_url),
|
||||
issueTitle = getString(R.string.crash_report_issue_title),
|
||||
),
|
||||
)
|
||||
reconcileAutoBackup()
|
||||
|
||||
@@ -32,6 +32,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
@@ -113,11 +114,26 @@ class MainActivity : AppCompatActivity() {
|
||||
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
||||
Configuration.UI_MODE_NIGHT_YES
|
||||
applyEdgeToEdge()
|
||||
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||
// Android hands the intent that started the task back to onCreate every
|
||||
// time the activity is recreated — on a rotation, or on a restart after
|
||||
// the system reclaimed the process. Re-running a launch that opens one
|
||||
// specific event is what made a single reminder or widget tap re-open
|
||||
// that event on every later launch, an error screen once the event had
|
||||
// been deleted (#309); the user left it long ago, and the activity
|
||||
// restores where they actually were along with its saved state.
|
||||
//
|
||||
// Only those channels are held back. An .ics import or a prefilled
|
||||
// create form keeps no saved state of its own, so re-reading the intent
|
||||
// is what carries one through a recreation.
|
||||
val replaying = savedInstanceState != null || intent.isRelaunch()
|
||||
if (!replaying) {
|
||||
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||
requestedEditKey = intent.editEventKeyOrNull()
|
||||
}
|
||||
requestedNav = intent.navRequestOrNull()
|
||||
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
|
||||
requestedImportUri = intent.importUriOrNull()
|
||||
requestedInsert = intent.insertRequestOrNull()
|
||||
requestedEditKey = intent.editEventKeyOrNull()
|
||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||
setContent {
|
||||
// One activity-scoped SettingsViewModel drives both the theme here
|
||||
@@ -154,6 +170,7 @@ class MainActivity : AppCompatActivity() {
|
||||
// weeks and switching between the week and day view.
|
||||
val timelineZoom = rememberTimelineZoom(
|
||||
stored = settings.timelineScale,
|
||||
hours = settings.timelineHours,
|
||||
onPersist = settingsViewModel::setTimelineScale,
|
||||
)
|
||||
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
||||
@@ -173,6 +190,7 @@ class MainActivity : AppCompatActivity() {
|
||||
LocalUse24HourFormat provides use24Hour,
|
||||
LocalShowHourGrid provides settings.showHourGrid,
|
||||
LocalTimelineZoom provides timelineZoom,
|
||||
LocalMultiDayCount provides settings.multiDayCount,
|
||||
LocalSoftenColors provides settings.softenColors,
|
||||
) {
|
||||
RootScreen(
|
||||
@@ -242,6 +260,16 @@ class MainActivity : AppCompatActivity() {
|
||||
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this launch is the task being resumed rather than a fresh
|
||||
* delivery — Android sets the flag when an activity is started from the
|
||||
* recents list, where the intent it carries is the one the task was started
|
||||
* with however long ago. `onNewIntent` is the channel a real new request
|
||||
* arrives on while the task lives, so nothing is lost by ignoring these.
|
||||
*/
|
||||
private fun Intent.isRelaunch(): Boolean =
|
||||
flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
|
||||
|
||||
/**
|
||||
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
|
||||
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
|
||||
|
||||
+28
-15
@@ -1,22 +1,35 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
/**
|
||||
* Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` /
|
||||
* `EVENT_COLOR`. The named entries exist for callers that need one specific
|
||||
* hue (the managed special-dates calendars), so they can't drift from the
|
||||
* swatches offered in the colour picker.
|
||||
* The colours offered when creating or editing a calendar; ARGB ints for a raw
|
||||
* `CALENDAR_COLOR` / `EVENT_COLOR`.
|
||||
*
|
||||
* Hues rather than final fills: every surface that draws a calendar colour runs
|
||||
* it through the app's own tone pass first, so these are chosen to stay
|
||||
* distinguishable after it. Shared with Agendula, whose list palette these are —
|
||||
* the two apps are the same family and a calendar and a task list picked from
|
||||
* different-looking sets read as different products.
|
||||
*
|
||||
* The named entries exist for callers that need one specific hue (the managed
|
||||
* special-dates calendars), so they can't drift from the swatches on offer.
|
||||
*/
|
||||
object CalendarColorPalette {
|
||||
val Red = 0xFFD50000.toInt()
|
||||
val Orange = 0xFFE67C00.toInt()
|
||||
val Amber = 0xFFF6BF26.toInt()
|
||||
val Green = 0xFF33B679.toInt()
|
||||
val DarkGreen = 0xFF0B8043.toInt()
|
||||
val Blue = 0xFF039BE5.toInt()
|
||||
val Indigo = 0xFF3F51B5.toInt()
|
||||
val Purple = 0xFF8E24AA.toInt()
|
||||
val Graphite = 0xFF616161.toInt()
|
||||
val Mauve = 0xFF7A5C6B.toInt()
|
||||
val Red = 0xFFD7484A.toInt()
|
||||
val Orange = 0xFFE8743B.toInt()
|
||||
val Amber = 0xFFE0A32E.toInt()
|
||||
val Olive = 0xFF7CA83E.toInt()
|
||||
val Green = 0xFF35A06A.toInt()
|
||||
val Teal = 0xFF19938C.toInt()
|
||||
val Cyan = 0xFF2A9BC4.toInt()
|
||||
val Blue = 0xFF3C74C8.toInt()
|
||||
val Indigo = 0xFF6A5CC0.toInt()
|
||||
val Purple = 0xFF9455B8.toInt()
|
||||
val Pink = 0xFFC94F8E.toInt()
|
||||
|
||||
/** The full palette, in swatch-row order. */
|
||||
val all: List<Int> = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite)
|
||||
/** The full palette, in swatch-grid order. */
|
||||
val all: List<Int> = listOf(
|
||||
Mauve, Red, Orange, Amber, Olive, Green,
|
||||
Teal, Cyan, Blue, Indigo, Purple, Pink,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -376,7 +376,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
.build()
|
||||
|
||||
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||
val name = displayName.trim()
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||
@@ -400,7 +400,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
|
||||
override fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) {
|
||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||
val name = displayName.trim()
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
||||
put(CalendarContract.Calendars.NAME, name)
|
||||
@@ -464,7 +464,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||
val name = displayName.trim()
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||
|
||||
@@ -8,8 +8,9 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
||||
val isLocal = accountType == CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||
return CalendarSource(
|
||||
id = getLong(CalendarProjection.IDX_ID),
|
||||
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
|
||||
?: Fallbacks.UNNAMED_CALENDAR,
|
||||
// Blank when the provider row has no name: the placeholder is a display
|
||||
// string and belongs to the UI, where it can be localised (#329).
|
||||
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME).orEmpty(),
|
||||
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
|
||||
accountType = accountType,
|
||||
color = getInt(CalendarProjection.IDX_COLOR),
|
||||
|
||||
@@ -21,8 +21,9 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
||||
return null
|
||||
}
|
||||
|
||||
val rawTitle = getString(InstanceProjection.IDX_TITLE)
|
||||
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
||||
// Left blank when the event has no title: the placeholder is a display
|
||||
// string, so it belongs to the UI layer where it can be localised (#321).
|
||||
val title = getString(InstanceProjection.IDX_TITLE).orEmpty()
|
||||
|
||||
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
|
||||
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
|
||||
|
||||
@@ -312,8 +312,3 @@ internal object ReminderProjection {
|
||||
const val IDX_MINUTES = 0
|
||||
const val IDX_METHOD = 1
|
||||
}
|
||||
|
||||
internal object Fallbacks {
|
||||
const val UNNAMED_CALENDAR = "(Unbenannter Kalender)"
|
||||
const val UNTITLED_EVENT = "(Ohne Titel)"
|
||||
}
|
||||
|
||||
@@ -21,8 +21,7 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
||||
}.coerceAtLeast(dtStart)
|
||||
|
||||
val rawTitle = getString(SearchProjection.IDX_TITLE)
|
||||
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
||||
val title = getString(SearchProjection.IDX_TITLE).orEmpty()
|
||||
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
|
||||
getInt(SearchProjection.IDX_CALENDAR_COLOR)
|
||||
} else {
|
||||
|
||||
@@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.common.storageValue
|
||||
@@ -287,6 +288,36 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
|
||||
}
|
||||
|
||||
/** How many days the multi-day view shows side by side (#193). */
|
||||
val multiDayCount: Flow<Int> = store.data.map { prefs ->
|
||||
(prefs[MULTI_DAY_COUNT_KEY] ?: DEFAULT_MULTI_DAY_COUNT).coerceIn(MULTI_DAY_COUNTS)
|
||||
}
|
||||
|
||||
suspend fun setMultiDayCount(count: Int) {
|
||||
store.edit { it[MULTI_DAY_COUNT_KEY] = count.coerceIn(MULTI_DAY_COUNTS) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The hours the day, week and multi-day timelines fit to the screen when they
|
||||
* open (#322). Off by default; the hours survive switching it off.
|
||||
*/
|
||||
val timelineHours: Flow<TimelineHours> = store.data.map { prefs ->
|
||||
TimelineHours.of(
|
||||
enabled = prefs[TIMELINE_HOURS_ENABLED_KEY] ?: false,
|
||||
startHour = prefs[TIMELINE_HOURS_START_KEY] ?: TimelineHours.DEFAULT_START_HOUR,
|
||||
endHour = prefs[TIMELINE_HOURS_END_KEY] ?: TimelineHours.DEFAULT_END_HOUR,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setTimelineHours(hours: TimelineHours) {
|
||||
val valid = TimelineHours.of(hours.enabled, hours.startHour, hours.endHour)
|
||||
store.edit {
|
||||
it[TIMELINE_HOURS_ENABLED_KEY] = valid.enabled
|
||||
it[TIMELINE_HOURS_START_KEY] = valid.startHour
|
||||
it[TIMELINE_HOURS_END_KEY] = valid.endHour
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 "+"
|
||||
@@ -586,6 +617,24 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the wizard's calendar step has been answered (or skipped). */
|
||||
val onboardingCalendarsDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_CALENDARS_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingCalendarsDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_CALENDARS_KEY] = done }
|
||||
}
|
||||
|
||||
/** Whether the wizard's hidden-calendars step has been answered (or skipped). */
|
||||
val onboardingHiddenCalendarsDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_HIDDEN_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingHiddenCalendarsDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_HIDDEN_KEY] = done }
|
||||
}
|
||||
|
||||
/** Whether the wizard's backup step has been answered (or skipped). */
|
||||
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_BACKUP_KEY] ?: false
|
||||
@@ -930,10 +979,20 @@ class SettingsPrefs @Inject constructor(
|
||||
if (view in config.enabled) view.name else "!${view.name}"
|
||||
}
|
||||
|
||||
/** Keep the given order (de-duplicated), then append any views it omits. */
|
||||
/** Keep stored order; insert the new multi-day view after Week on upgrades. */
|
||||
private fun completeViewOrder(seen: List<CalendarView>): List<CalendarView> {
|
||||
val ordered = seen.distinct()
|
||||
return ordered + IMPLEMENTED_VIEWS.filterNot { it in ordered }
|
||||
val ordered = seen.distinct().toMutableList()
|
||||
IMPLEMENTED_VIEWS.forEach { view ->
|
||||
if (view !in ordered) {
|
||||
val index = if (view == CalendarView.MultiDay && CalendarView.Week in ordered) {
|
||||
ordered.indexOf(CalendarView.Week) + 1
|
||||
} else {
|
||||
ordered.size
|
||||
}
|
||||
ordered.add(index, view)
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -977,6 +1036,10 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||
internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
|
||||
internal val ONBOARDING_CALENDARS_KEY =
|
||||
booleanPreferencesKey("onboarding_calendars_done")
|
||||
internal val ONBOARDING_HIDDEN_KEY =
|
||||
booleanPreferencesKey("onboarding_hidden_calendars_done")
|
||||
internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
|
||||
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
|
||||
internal val ONBOARDING_MONTH_STYLE_KEY =
|
||||
@@ -995,8 +1058,14 @@ class SettingsPrefs @Inject constructor(
|
||||
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
||||
private const val MINUTES_PER_DAY = 1_440
|
||||
internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes")
|
||||
internal val MULTI_DAY_COUNT_KEY = intPreferencesKey("multi_day_count")
|
||||
internal val TIMELINE_HOURS_ENABLED_KEY = booleanPreferencesKey("timeline_hours_enabled")
|
||||
internal val TIMELINE_HOURS_START_KEY = intPreferencesKey("timeline_hours_start")
|
||||
internal val TIMELINE_HOURS_END_KEY = intPreferencesKey("timeline_hours_end")
|
||||
/** Default snooze delay for the notification "Snooze" action. */
|
||||
const val DEFAULT_SNOOZE_MINUTES = 10
|
||||
const val DEFAULT_MULTI_DAY_COUNT = 3
|
||||
val MULTI_DAY_COUNTS = 3..6
|
||||
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
|
||||
stringPreferencesKey("per_calendar_reminder_override")
|
||||
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
|
||||
|
||||
@@ -88,6 +88,7 @@ class ReminderNotifier @Inject constructor(
|
||||
is24Hour = is24Hour,
|
||||
today = Instant.now().atZone(zone).toLocalDate(),
|
||||
firstDayOfWeek = firstDayOfWeek,
|
||||
todayLabel = context.getString(R.string.reminder_day_today),
|
||||
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
|
||||
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
|
||||
)
|
||||
|
||||
@@ -27,12 +27,12 @@ import java.util.Locale
|
||||
* alone would be ambiguous)
|
||||
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time,
|
||||
* already unambiguous)
|
||||
* - all-day, one day: "11 Jun 2026"
|
||||
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
|
||||
* - all-day, one day: "Tomorrow" / "Thu" / "11 Jun 2026" (same relative rules,
|
||||
* plus [todayLabel] — no time range to lean on)
|
||||
* - all-day, multi-day: "Tomorrow – 12 Jun 2026" (relative start, explicit end)
|
||||
*
|
||||
* All-day instances already carry an explicit date, so they never gain a
|
||||
* relative prefix. They store UTC midnights with an exclusive end, so they are
|
||||
* read in UTC and the end day is the last *covered* day.
|
||||
* All-day instances store UTC midnights with an exclusive end, so they are read
|
||||
* in UTC and the end day is the last *covered* day (issue #308).
|
||||
*/
|
||||
fun reminderTimeText(
|
||||
beginMillis: Long,
|
||||
@@ -43,6 +43,7 @@ fun reminderTimeText(
|
||||
is24Hour: Boolean,
|
||||
today: LocalDate,
|
||||
firstDayOfWeek: DayOfWeek,
|
||||
todayLabel: String,
|
||||
tomorrowLabel: String,
|
||||
yesterdayLabel: String,
|
||||
): String {
|
||||
@@ -53,11 +54,16 @@ fun reminderTimeText(
|
||||
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
.minusDays(1)
|
||||
.coerceAtLeast(firstDay)
|
||||
return if (lastDay == firstDay) {
|
||||
dateFormat.format(firstDay)
|
||||
} else {
|
||||
dateFormat.format(firstDay) + RANGE + dateFormat.format(lastDay)
|
||||
}
|
||||
val start = relativeDayPrefix(
|
||||
day = firstDay,
|
||||
today = today,
|
||||
firstDayOfWeek = firstDayOfWeek,
|
||||
locale = locale,
|
||||
dateFormat = dateFormat,
|
||||
tomorrowLabel = tomorrowLabel,
|
||||
yesterdayLabel = yesterdayLabel,
|
||||
) ?: todayLabel
|
||||
return if (lastDay == firstDay) start else start + RANGE + dateFormat.format(lastDay)
|
||||
}
|
||||
|
||||
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
||||
|
||||
@@ -46,3 +46,19 @@ fun hasSystemHiddenCalendars(
|
||||
calendars: List<CalendarSource>,
|
||||
pendingDisabledIds: Set<Long>,
|
||||
): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds }
|
||||
|
||||
/**
|
||||
* The failure a calendar list implies, or null when there is something to show.
|
||||
* Distinguishes "the device has no calendars" from "every one of them is
|
||||
* switched off" (#239) — both leave the grid empty, but only the second is
|
||||
* fixed from Settings → Calendars.
|
||||
*
|
||||
* The filter sheet's own hidden set is deliberately not folded in: emptying it
|
||||
* is a deliberate act taken moments earlier, in a sheet that is still one tap
|
||||
* away.
|
||||
*/
|
||||
fun calendarListFailure(calendars: List<CalendarSource>): FailureReason? = when {
|
||||
calendars.isEmpty() -> FailureReason.NoCalendarsConfigured
|
||||
calendars.none { it.isVisibleInSystem } -> FailureReason.AllCalendarsHidden
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.TimeZone
|
||||
|
||||
/** Lower values appear first in lists and get the upper visible lanes in grids. */
|
||||
fun EventInstance.displayPriority(zone: TimeZone): Int = when {
|
||||
isDeclined -> 3
|
||||
spansMultipleDays(zone) -> 0
|
||||
isAllDay -> 1
|
||||
else -> 2
|
||||
}
|
||||
|
||||
/** Shared order for agenda and split day lists. Grid bars add their lane geometry. */
|
||||
fun eventDisplayComparator(zone: TimeZone): Comparator<EventInstance> =
|
||||
compareBy<EventInstance> { it.displayPriority(zone) }
|
||||
.thenBy { it.start }
|
||||
.thenBy { it.title }
|
||||
.thenBy { it.instanceId }
|
||||
@@ -252,6 +252,14 @@ enum class RecurringWriteScope {
|
||||
enum class FailureReason {
|
||||
PermissionRevoked,
|
||||
NoCalendarsConfigured,
|
||||
AllCalendarsHidden,
|
||||
|
||||
/**
|
||||
* Calendars exist and are switched on, but none can receive an event: every
|
||||
* one is read-only, app-managed, or not synced to this device. Distinct from
|
||||
* [AllCalendarsHidden], which a visibility switch fixes.
|
||||
*/
|
||||
NoImportTarget,
|
||||
ProviderUnavailable,
|
||||
EventNotFound,
|
||||
Unknown,
|
||||
|
||||
@@ -166,14 +166,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
}
|
||||
when {
|
||||
component != null -> {
|
||||
// A component whose END line never arrives ends at the next
|
||||
// one's BEGIN, not at that one's END: reading to the far END
|
||||
// would fold two events into one body, where the later
|
||||
// properties overwrite the earlier and one event is lost.
|
||||
val end = indexOfEnd(lines, i + 1, component)
|
||||
val body = indexOfNextComponent(lines, i + 1, end)
|
||||
parseComponent(
|
||||
body = lines.subList(i + 1, end),
|
||||
body = lines.subList(i + 1, body),
|
||||
fileCalendarName = calendarName,
|
||||
warnings = warnings,
|
||||
isTask = component == "VTODO",
|
||||
)?.let(events::add)
|
||||
i = end + 1
|
||||
i = if (body < end) body else end + 1
|
||||
}
|
||||
line.isBegin("VTIMEZONE") -> {
|
||||
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
||||
@@ -445,6 +450,23 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
}
|
||||
|
||||
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
||||
/**
|
||||
* Where an unterminated component's body has to stop: the next top-level
|
||||
* `VEVENT` / `VTODO` in `[from, end)`, or [end] when there is none. A
|
||||
* nested `VALARM` is part of the body and is not a boundary.
|
||||
*/
|
||||
fun indexOfNextComponent(lines: List<String>, from: Int, end: Int): Int {
|
||||
var i = from
|
||||
while (i < end) {
|
||||
val line = parseContentLine(lines[i])
|
||||
if (line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))) {
|
||||
return i
|
||||
}
|
||||
i++
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
||||
var i = from
|
||||
while (i < lines.size) {
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.SeekableTransitionState
|
||||
import androidx.compose.animation.core.rememberTransition
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.jeanlucmakiola.calendula.ui.common.screen
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewScreen
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInWindow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
@@ -26,17 +42,38 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||
import de.jeanlucmakiola.calendula.ui.common.BelowTopBarClip
|
||||
import de.jeanlucmakiola.calendula.ui.common.StayingPut
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFab
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarTopBar
|
||||
import de.jeanlucmakiola.calendula.ui.common.ChromeHost
|
||||
import de.jeanlucmakiola.calendula.ui.common.ChromeSlot
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalChromeSlot
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBackExit
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
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.LocalManageCalendars
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScroll
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewFocus
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewFocusSaver
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
|
||||
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScrollMemory
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewMorphScope
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberViewMorphBoundsTransform
|
||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.drillToWeek
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.selectView
|
||||
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
||||
@@ -44,16 +81,21 @@ import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
||||
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimelineScreen
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Holds the top-level view back stack (spec M1) and swaps between the calendar
|
||||
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
|
||||
* pill in their top bars writes back here via [onSelectView].
|
||||
* screens. Each screen owns its own ViewModel; they all open on the one focused
|
||||
* date held here ([ViewFocus]), and the view-switcher pill in their top bars
|
||||
* writes back here via [onSelectView].
|
||||
*
|
||||
* The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view.
|
||||
* A lateral switch (pill / drawer) builds a visit history so back retraces it
|
||||
@@ -70,6 +112,7 @@ import kotlin.time.Clock
|
||||
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
|
||||
* widget's view.)
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun CalendarHost(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -101,14 +144,25 @@ fun CalendarHost(
|
||||
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
||||
mutableStateOf(listOf(defaultView))
|
||||
}
|
||||
var navigationVersion by remember { mutableIntStateOf(0) }
|
||||
val view = viewStack.last()
|
||||
val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) }
|
||||
// The date every view opens on and carries along (#184); tapping a day
|
||||
// anywhere focuses it and opens the day view there.
|
||||
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) }
|
||||
|
||||
// Tapping a day in the month grid opens the day view anchored to that date.
|
||||
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val switchScope = rememberCoroutineScope()
|
||||
val switchTo: ((List<CalendarView>) -> List<CalendarView>) -> Unit = { move ->
|
||||
navigationVersion++
|
||||
viewStack = move(viewStack)
|
||||
}
|
||||
val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } }
|
||||
val onOpenDay: (LocalDate) -> Unit = { date ->
|
||||
pendingDayIso = date.toString()
|
||||
viewStack = viewStack.drillToDay()
|
||||
focus.navigateTo(date)
|
||||
switchTo { it.drillToDay() }
|
||||
}
|
||||
val onOpenWeek: (LocalDate) -> Unit = { date ->
|
||||
focus.navigateTo(date)
|
||||
switchTo { it.drillToWeek() }
|
||||
}
|
||||
|
||||
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
||||
@@ -196,10 +250,21 @@ fun CalendarHost(
|
||||
// is "restore a backup", not "add this one event". An externally opened .ics
|
||||
// keeps routing a single event straight into the prefilled create form.
|
||||
var importForceMany by remember { mutableStateOf(false) }
|
||||
// Where a restore came from, so closing the import puts it back: the import
|
||||
// overlays are declared under Backup & restore and the manager, so starting
|
||||
// one has to close them — without this, finishing a restore drops you on the
|
||||
// calendar and the next file means walking in through Settings again.
|
||||
var backupAfterImport by rememberSaveable { mutableStateOf(false) }
|
||||
var calendarsAfterImport by rememberSaveable { mutableStateOf(false) }
|
||||
// One import run. The import VM lives in the Activity's store, so this is
|
||||
// what tells it a *re-import of the same file* is new work and not the run
|
||||
// it already finished; a rotation keeps the number and keeps the result.
|
||||
var importSession by rememberSaveable { mutableStateOf(0) }
|
||||
LaunchedEffect(requestedImportUri) {
|
||||
if (requestedImportUri != null) {
|
||||
importUri = requestedImportUri
|
||||
importForceMany = false
|
||||
importSession++
|
||||
onImportConsumed()
|
||||
}
|
||||
}
|
||||
@@ -247,13 +312,14 @@ fun CalendarHost(
|
||||
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
||||
// view so backing out returns there (then home), not to the default.
|
||||
LaunchedEffect(widgetNavRequest) {
|
||||
if (widgetNavRequest != null) navigationVersion++
|
||||
when (val req = widgetNavRequest) {
|
||||
is WidgetNavRequest.OpenDate -> {
|
||||
// Drill the day view in over the widget's view: drop any overlay
|
||||
// that would cover it, so the open doesn't land under Settings/form.
|
||||
dismissCoveringOverlays()
|
||||
createDateIso = null
|
||||
pendingDayIso = req.dateIso
|
||||
focus.navigateTo(LocalDate.parse(req.dateIso))
|
||||
// No widget source (an external date tap) roots over the default
|
||||
// home view, so backing out of the day returns home then exits.
|
||||
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
|
||||
@@ -277,7 +343,7 @@ fun CalendarHost(
|
||||
// default home — so backing out returns to the default, then exits.
|
||||
dismissCoveringOverlays()
|
||||
createDateIso = null
|
||||
pendingDayIso = null
|
||||
focus.navigateTo(localToday())
|
||||
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
@@ -328,6 +394,9 @@ fun CalendarHost(
|
||||
}
|
||||
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
// Overlays leave from the predictive-back preview (#184): shrinking on from
|
||||
// where the gesture let go instead of snapping back and sliding off.
|
||||
val overlayExit = predictiveBackExit()
|
||||
|
||||
// Base-level back: pop the view stack while no overlay covers it (each overlay
|
||||
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
||||
@@ -335,71 +404,150 @@ fun CalendarHost(
|
||||
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
|
||||
editKey != null || showSettings || showCalendars || showBackup ||
|
||||
importUri != null || importForm != null
|
||||
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
|
||||
viewStack = viewStack.dropLast(1)
|
||||
// The view switch runs on a seekable transition, so a back gesture can scrub
|
||||
// it (#185): the previous view morphs in under the finger, releasing finishes
|
||||
// the pop and cancelling springs back. Reduced motion keeps a plain back.
|
||||
val viewTransition = remember { SeekableTransitionState(view) }
|
||||
LaunchedEffect(view) { viewTransition.animateTo(view) }
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val viewBackEnabled = !anyOverlayVisible && viewStack.size > 1
|
||||
if (reduceMotion) {
|
||||
BackHandler(enabled = viewBackEnabled) {
|
||||
navigationVersion++
|
||||
viewStack = viewStack.dropLast(1)
|
||||
}
|
||||
} else {
|
||||
PredictiveBackHandler(enabled = viewBackEnabled) { events ->
|
||||
val previous = viewStack[viewStack.size - 2]
|
||||
try {
|
||||
events.collect { viewTransition.seekTo(it.progress, targetState = previous) }
|
||||
navigationVersion++
|
||||
viewStack = viewStack.dropLast(1)
|
||||
} catch (e: CancellationException) {
|
||||
withContext(NonCancellable) { viewTransition.animateTo(viewTransition.currentState) }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
// Switching between the peer views (month/week/day/agenda) is lateral
|
||||
// Switching between the month, timeline and agenda screens is lateral
|
||||
// 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. What both views show morphs across
|
||||
// (#184): see [ViewMorphKey]. Reduced motion keeps the plain fade.
|
||||
val viewSwitch = fadeThrough()
|
||||
CompositionLocalProvider(LocalEventMove provides moveScope) {
|
||||
AnimatedContent(
|
||||
targetState = view,
|
||||
val morphBounds = rememberViewMorphBoundsTransform()
|
||||
val morphFade = rememberCalendarFadeSpec()
|
||||
val morphClip = remember { BelowTopBarClip() }
|
||||
val stayingPut = remember { StayingPut() }
|
||||
val chromeHost = remember { ChromeHost() }
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val density = LocalDensity.current
|
||||
val topBarBottom = TopAppBarDefaults.windowInsets.getTop(density) +
|
||||
with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
||||
val timelineScroll = remember { TimelineScrollMemory() }
|
||||
CompositionLocalProvider(
|
||||
LocalEventMove provides moveScope,
|
||||
LocalTimelineScroll provides timelineScroll,
|
||||
LocalViewFocus provides focus,
|
||||
// The failure state's way out when every calendar is switched off
|
||||
// (#239); nothing else in the calendar surfaces navigates here.
|
||||
LocalManageCalendars provides remember { { showCalendars = true } },
|
||||
) {
|
||||
// One drawer, top bar and FAB for every screen (#184): a switch changes
|
||||
// what they say, from what the screen on show publishes, rather than
|
||||
// swapping them for another screen's.
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
// Open only via the menu button where edge-swipe would fight paging;
|
||||
// the agenda has no paging to fight.
|
||||
gesturesEnabled = drawerState.isOpen || view.screen == ViewScreen.Agenda,
|
||||
drawerContent = {
|
||||
CalendarDrawer(
|
||||
currentView = view,
|
||||
currentDate = chromeHost.chrome?.drawerDate ?: focus.date,
|
||||
drawerState = drawerState,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { target ->
|
||||
// Once the drawer is out of the way, so the switch is seen whole.
|
||||
val requestVersion = ++navigationVersion
|
||||
switchScope.launch {
|
||||
drawerState.close()
|
||||
if (navigationVersion == requestVersion) onSelectView(target)
|
||||
}
|
||||
},
|
||||
onJumpToDate = { target ->
|
||||
chromeHost.actions?.onJumpToDate?.invoke(target)
|
||||
switchScope.launch { drawerState.close() }
|
||||
},
|
||||
onSettings = {
|
||||
onOpenSettings()
|
||||
switchScope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CalendarTopBar(
|
||||
host = chromeHost,
|
||||
selectedView = view,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
onCycleView = { onSelectView(viewStack.last().next(quickSwitchViews)) },
|
||||
onOpenDrawer = { switchScope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
showTodayButton = todayInToolbar,
|
||||
)
|
||||
},
|
||||
floatingActionButton = { CalendarFab(chromeHost, todayInToolbar) },
|
||||
) { innerPadding ->
|
||||
SharedTransitionLayout(
|
||||
Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
.onGloballyPositioned { morphClip.top = topBarBottom - it.positionInWindow().y },
|
||||
) {
|
||||
// Keyed on the screen, not the view: the day, multi-day and week views
|
||||
// are one timeline that resizes between them itself (#184), so switching
|
||||
// among them must not replay this transition.
|
||||
rememberTransition(viewTransition, label = "view-switch").AnimatedContent(
|
||||
transitionSpec = { viewSwitch },
|
||||
label = "view-switch",
|
||||
contentKey = { it.screen },
|
||||
) { currentView ->
|
||||
when (currentView) {
|
||||
CalendarView.Week -> WeekScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
val screen = currentView.screen
|
||||
val sharedScope = this@SharedTransitionLayout
|
||||
val visibilityScope = this@AnimatedContent
|
||||
val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut) {
|
||||
if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut)
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalViewMorph provides viewMorph,
|
||||
LocalChromeSlot provides ChromeSlot(chromeHost, current = screen == view.screen),
|
||||
) {
|
||||
when (screen) {
|
||||
ViewScreen.Timeline -> TimelineScreen(
|
||||
view = currentView,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
CalendarView.Day -> DayScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
ViewScreen.Month -> MonthScreen(
|
||||
onOpenDay = onOpenDay,
|
||||
onOpenWeek = onOpenWeek,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
initialDateIso = pendingDayIso,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
CalendarView.Month -> MonthScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
ViewScreen.Agenda -> AgendaScreen(
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
CalendarView.Agenda -> AgendaScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,11 +560,12 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = showSearch,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
SearchScreen(
|
||||
onBack = { showSearch = false },
|
||||
onEventClick = onEventClick,
|
||||
visible = showSearch,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -425,7 +574,7 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = detailKey != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
activeKey?.let { key ->
|
||||
EventDetailScreen(
|
||||
@@ -445,6 +594,7 @@ fun CalendarHost(
|
||||
importForm = form
|
||||
detailKey = null
|
||||
},
|
||||
visible = detailKey != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -453,7 +603,7 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = createDateIso != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
(createDateIso ?: heldCreateIso)?.let { iso ->
|
||||
EventEditScreen(
|
||||
@@ -462,6 +612,7 @@ fun CalendarHost(
|
||||
onClose = { createDateIso = null },
|
||||
onSaved = { createDateIso = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
visible = createDateIso != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -470,7 +621,7 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = editKey != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
(editKey ?: heldEditKey)?.let { key ->
|
||||
EventEditScreen(
|
||||
@@ -482,6 +633,7 @@ fun CalendarHost(
|
||||
detailKey = null
|
||||
},
|
||||
onManageCalendars = { showCalendars = true },
|
||||
visible = editKey != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -490,12 +642,13 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = showSettings,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
SettingsScreen(
|
||||
onBack = { showSettings = false },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
onOpenBackup = { showBackup = true },
|
||||
visible = showSettings,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -504,11 +657,28 @@ fun CalendarHost(
|
||||
importUri?.let { uri ->
|
||||
ImportScreen(
|
||||
uri = uri,
|
||||
session = importSession,
|
||||
forceMany = importForceMany,
|
||||
onClose = { importUri = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
onClose = {
|
||||
importUri = null
|
||||
// Back to the surface the restore started from, ready for
|
||||
// the next file.
|
||||
showCalendars = calendarsAfterImport
|
||||
showBackup = backupAfterImport
|
||||
calendarsAfterImport = false
|
||||
backupAfterImport = false
|
||||
},
|
||||
onManageCalendars = {
|
||||
// The manager is where this leads, so it must not be
|
||||
// reopened underneath on close.
|
||||
calendarsAfterImport = false
|
||||
backupAfterImport = false
|
||||
showCalendars = true
|
||||
},
|
||||
onOpenSingle = { form ->
|
||||
importUri = null
|
||||
calendarsAfterImport = false
|
||||
backupAfterImport = false
|
||||
importFormSource = ImportSource.File
|
||||
importForm = form
|
||||
},
|
||||
@@ -530,11 +700,12 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = showCalendars,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
CalendarsScreen(
|
||||
onBack = { showCalendars = false },
|
||||
onOpenBackup = { showBackup = true },
|
||||
visible = showCalendars,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -542,20 +713,36 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = showBackup,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
BackupScreen(
|
||||
onBack = { showBackup = false },
|
||||
// Restore runs the normal .ics import, and both this screen and
|
||||
// the manager that can have opened it are declared above the
|
||||
// import overlays — so both have to step aside.
|
||||
onImport = {
|
||||
importUri = it
|
||||
importForceMany = true
|
||||
// Settings can open this screen without the manager underneath, so
|
||||
// its failure states' "Manage calendars" way out has to open the
|
||||
// manager rather than just pop — popping alone lands on Settings
|
||||
// (#304). Coming from the manager the flag is already set, so this
|
||||
// is the same pop-back there.
|
||||
CompositionLocalProvider(
|
||||
LocalManageCalendars provides {
|
||||
showCalendars = true
|
||||
showBackup = false
|
||||
showCalendars = false
|
||||
},
|
||||
)
|
||||
) {
|
||||
BackupScreen(
|
||||
onBack = { showBackup = false },
|
||||
// Restore runs the normal .ics import, and both this screen
|
||||
// and the manager that can have opened it are declared above
|
||||
// the import overlays — so both have to step aside.
|
||||
onImport = {
|
||||
importUri = it
|
||||
importForceMany = true
|
||||
importSession++
|
||||
backupAfterImport = true
|
||||
calendarsAfterImport = showCalendars
|
||||
showBackup = false
|
||||
showCalendars = false
|
||||
},
|
||||
visible = showBackup,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -565,3 +752,6 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
|
||||
save = { stack -> stack.map(CalendarView::ordinal) },
|
||||
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
|
||||
)
|
||||
|
||||
private fun localToday(): LocalDate =
|
||||
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.PublishChrome
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScreenChrome
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
|
||||
import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
|
||||
import de.jeanlucmakiola.calendula.ui.common.morphEvent
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -13,35 +26,27 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Coffee
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -54,14 +59,9 @@ import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
@@ -77,26 +77,22 @@ import kotlin.time.Instant
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AgendaScreen(
|
||||
selectedView: CalendarView,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
todayInToolbar: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AgendaViewModel = hiltViewModel(),
|
||||
) {
|
||||
// Opens on the focused date and hands back the day scrolled to the top.
|
||||
val focus = LocalViewFocus.current
|
||||
EnterOnFocus(viewModel) { if (it != viewModel.anchor.value) viewModel.goToDate(it) }
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
var showRangePicker by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -106,103 +102,102 @@ fun AgendaScreen(
|
||||
}
|
||||
val successState = state as? AgendaUiState.Success
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = anchor,
|
||||
drawerState = drawerState,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onJumpToDate = { target ->
|
||||
viewModel.goToDate(target)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onSettings = {
|
||||
onOpenSettings()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
AgendaTopBar(
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = viewModel::goToToday,
|
||||
)
|
||||
// The agenda window always starts at the anchor, so moving through it is a
|
||||
// list scroll, not an anchor change — "today" has to bring the list back to
|
||||
// the top as well, and stay offered while it is scrolled away (#305).
|
||||
val listState = rememberLazyListState()
|
||||
val scrolledAway by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
|
||||
}
|
||||
}
|
||||
// A new window is rendered from its first day, so the list belongs at the
|
||||
// top. Keyed on the anchor the days arrived with, not the one just
|
||||
// requested: scrolling before the new rows compose leaves the list keyed to
|
||||
// a row that reappears further down, and it follows it there.
|
||||
LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) }
|
||||
val jumpToToday: () -> Unit = {
|
||||
// Off today, moving the anchor is what resets the list (above); on today
|
||||
// the window doesn't change, so the scroll back is the whole action.
|
||||
if (isOnToday) scope.launch { listState.animateScrollToItem(0) }
|
||||
viewModel.goToToday()
|
||||
(state as? AgendaUiState.Success)?.today?.let { focus?.date = it }
|
||||
}
|
||||
|
||||
PublishChrome(
|
||||
ScreenChrome(
|
||||
title = stringResource(R.string.view_agenda),
|
||||
titleDate = null,
|
||||
drawerDate = anchor,
|
||||
awayFromToday = !isOnToday || (scrolledAway && successState != null),
|
||||
todayText = stringResource(R.string.agenda_today_action),
|
||||
),
|
||||
ScreenActions(
|
||||
onToday = jumpToToday,
|
||||
onJumpToDate = { target ->
|
||||
focus?.date = target
|
||||
viewModel.goToDate(target)
|
||||
},
|
||||
floatingActionButton = {
|
||||
CalendarFabColumn(
|
||||
todayVisible = !isOnToday && !todayInToolbar,
|
||||
todayText = stringResource(R.string.agenda_today_action),
|
||||
onToday = viewModel::goToToday,
|
||||
onCreate = { onCreateEvent(anchor, null) },
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
// One bar at the top: the "showing …" header on the left and the
|
||||
// session range switcher on the right (one settings toggle).
|
||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||
// end lines the selector up with whatever ends the top bar:
|
||||
// the view switcher's background, or — once #150 hides it —
|
||||
// the search icon's glyph.
|
||||
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
|
||||
AppBarSpacing.Inset
|
||||
} else {
|
||||
AppBarSpacing.IconTrailingInset
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = RANGE_BAR_TEXT_INSET,
|
||||
end = selectorEnd,
|
||||
top = 8.dp,
|
||||
bottom = 8.dp,
|
||||
),
|
||||
) {
|
||||
AgendaRangeBanner(
|
||||
range = s.range,
|
||||
start = s.anchor,
|
||||
end = s.rangeEnd,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
AgendaRangePill(
|
||||
range = s.range,
|
||||
isOverride = s.rangeIsOverride,
|
||||
onClick = { showRangePicker = true },
|
||||
)
|
||||
}
|
||||
onCreate = { onCreateEvent(anchor, null) },
|
||||
),
|
||||
)
|
||||
|
||||
ScreenBody(modifier = modifier) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
// One bar at the top: the "showing …" header on the left and the
|
||||
// session range switcher on the right (one settings toggle).
|
||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||
// end lines the selector up with whatever ends the top bar:
|
||||
// the view switcher's background, or — once #150 hides it —
|
||||
// the search icon's glyph.
|
||||
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
|
||||
AppBarSpacing.Inset
|
||||
} else {
|
||||
AppBarSpacing.IconTrailingInset
|
||||
}
|
||||
AgendaContent(
|
||||
state = state,
|
||||
pastDisplay = pastDisplay,
|
||||
showToday = showToday,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = RANGE_BAR_TEXT_INSET,
|
||||
end = selectorEnd,
|
||||
top = 8.dp,
|
||||
bottom = 8.dp,
|
||||
),
|
||||
) {
|
||||
AgendaRangeBanner(
|
||||
range = s.range,
|
||||
start = s.anchor,
|
||||
end = s.rangeEnd,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
AgendaRangePill(
|
||||
range = s.range,
|
||||
isOverride = s.rangeIsOverride,
|
||||
onClick = { showRangePicker = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
AgendaContent(
|
||||
state = state,
|
||||
pastDisplay = pastDisplay,
|
||||
showToday = showToday,
|
||||
listState = listState,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onTopDay = if (successState?.anchor == anchor) {
|
||||
{ focus?.date = it }
|
||||
} else null,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,6 +293,8 @@ internal fun AgendaContent(
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
onTopDay: ((LocalDate) -> Unit)? = null,
|
||||
) {
|
||||
when (state) {
|
||||
AgendaUiState.Loading -> Box(modifier)
|
||||
@@ -326,16 +323,21 @@ internal fun AgendaContent(
|
||||
enabled = showToday && state.anchor == state.today,
|
||||
)
|
||||
if (days.isEmpty()) {
|
||||
// Nothing to scroll: drop the position a previous list left, so
|
||||
// the today FAB doesn't linger over an empty screen.
|
||||
LaunchedEffect(Unit) { listState.scrollToItem(0) }
|
||||
AgendaEmpty(modifier)
|
||||
} else {
|
||||
AgendaList(
|
||||
days = days,
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
listState = listState,
|
||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||
now = now,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onTopDay = onTopDay,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -349,14 +351,28 @@ private fun AgendaList(
|
||||
days: List<AgendaDay>,
|
||||
today: LocalDate,
|
||||
zone: TimeZone,
|
||||
listState: LazyListState,
|
||||
dimPast: Boolean,
|
||||
now: Instant,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onTopDay: ((LocalDate) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (onTopDay != null) {
|
||||
val itemDates by rememberUpdatedState(agendaItemDates(days))
|
||||
val currentOnTopDay by rememberUpdatedState(onTopDay)
|
||||
// The day the list opened on is where the view already is; only a
|
||||
// scroll moves it on.
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.firstVisibleItemIndex }
|
||||
.drop(1)
|
||||
.collect { index -> itemDates.getOrNull(index)?.let(currentOnTopDay) }
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
state = listState,
|
||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||
) {
|
||||
@@ -386,7 +402,7 @@ private fun AgendaList(
|
||||
zone = zone,
|
||||
position = positionOf(index, day.events.size),
|
||||
dimmed = dimPast && event.hasEnded(now),
|
||||
modifier = animateItemMotion(),
|
||||
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
}
|
||||
@@ -396,6 +412,13 @@ private fun AgendaList(
|
||||
}
|
||||
}
|
||||
|
||||
/** The day of each item [AgendaList] lays out for [days], in its order. */
|
||||
internal fun agendaItemDates(days: List<AgendaDay>): List<LocalDate> =
|
||||
days.flatMap { day ->
|
||||
// Header, the placeholder or the events, then the gap.
|
||||
List(2 + day.events.size.coerceAtLeast(1)) { day.date }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
@@ -417,58 +440,3 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AgendaTopBar(
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
quickSwitchViews: List<CalendarView>,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
// A plain label rather than a CalendarTitleButton, so it takes that
|
||||
// one's inset and one-line clamp itself.
|
||||
Text(
|
||||
text = stringResource(R.string.view_agenda),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = AppBarSpacing.TitleInset),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(R.string.month_open_menu),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TodayAction(show = showTodayButton, onToday = onToday)
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = stringResource(R.string.search_action),
|
||||
)
|
||||
}
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
cycle = quickSwitchViews,
|
||||
onCycle = onCycleView,
|
||||
)
|
||||
},
|
||||
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
||||
// it, but here the bar meets the header on the same surface and the
|
||||
// tint is what makes that seam look like a separate block (#186).
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.spanLastDay
|
||||
@@ -86,11 +87,7 @@ fun groupAgendaDays(
|
||||
return byDay.map { (date, dayEvents) ->
|
||||
AgendaDay(
|
||||
date = date,
|
||||
events = dayEvents.sortedWith(
|
||||
compareByDescending<EventInstance> { it.isAllDay }
|
||||
.thenBy { it.start }
|
||||
.thenBy { it.title },
|
||||
),
|
||||
events = dayEvents.sortedWith(eventDisplayComparator(zone)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -156,9 +157,7 @@ class AgendaViewModel @Inject constructor(
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): AgendaUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
calendarListFailure(calendars)?.let { return AgendaUiState.Failure(it) }
|
||||
val anchor = params.anchor
|
||||
val rangeEnd = anchor.plus(
|
||||
params.range.dayCount(anchor, params.weekStart) - 1,
|
||||
|
||||
@@ -4,9 +4,11 @@ import android.net.Uri
|
||||
import android.text.format.DateUtils
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -18,6 +20,7 @@ import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
@@ -47,9 +50,10 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||
@@ -78,20 +82,16 @@ private val RESTORE_MIME_TYPES = arrayOf(
|
||||
fun BackupScreen(
|
||||
onBack: () -> Unit,
|
||||
onImport: (Uri) -> Unit,
|
||||
visible: Boolean = true,
|
||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||
val state by viewModel.backupState.collectAsStateWithLifecycle()
|
||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// Export covers local calendars only; managed special-dates mirrors are
|
||||
// rebuilt from contacts. Restore can target anything the import picker offers.
|
||||
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||
val canImport = calendars.any { it.isEventTarget }
|
||||
|
||||
// Exports everything eligible (null); the per-calendar selector owns its
|
||||
// own launcher.
|
||||
val createBackup = rememberLauncherForActivityResult(
|
||||
@@ -132,78 +132,43 @@ fun BackupScreen(
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_backup),
|
||||
onBack = onBack,
|
||||
// Loading and failure fill the screen and centre themselves, which they
|
||||
// can only do in an unscrolled column — the scrolling one measures them
|
||||
// against an unbounded height and leaves them hanging under the header.
|
||||
scrollable = state is BackupUiState.Ready,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
HintText(stringResource(R.string.calendars_backup_hint))
|
||||
|
||||
if (exportable.isNotEmpty()) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_backup_action),
|
||||
position = Position.Top,
|
||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||
onClick = {
|
||||
// A single exportable calendar skips the selector.
|
||||
if (exportable.size == 1) {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
} else {
|
||||
showExportPicker = true
|
||||
}
|
||||
when (val s = state) {
|
||||
BackupUiState.Loading -> BackupLoading()
|
||||
is BackupUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onBack)
|
||||
is BackupUiState.Ready -> BackupContent(
|
||||
exportable = s.exportable,
|
||||
canImport = s.canImport,
|
||||
autoBackup = autoBackup,
|
||||
viewModel = viewModel,
|
||||
onRestore = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||
onPickFolder = { runCatching { pickFolder.launch(null) } },
|
||||
onExportAll = {
|
||||
runCatching { createBackup.launch(defaultBackupName()) }
|
||||
},
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_restore_action),
|
||||
summary = stringResource(R.string.calendars_restore_hint),
|
||||
position = Position.Middle,
|
||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup),
|
||||
summary = stringResource(R.string.calendars_auto_backup_hint),
|
||||
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
||||
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
||||
trailing = {
|
||||
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
|
||||
},
|
||||
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
|
||||
)
|
||||
if (autoBackup.enabled) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_folder),
|
||||
summary = rememberFolderName(autoBackup.folderUri)
|
||||
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
||||
position = Position.Middle,
|
||||
onClick = { runCatching { pickFolder.launch(null) } },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = { showInterval = true },
|
||||
)
|
||||
HintText(backupStatusText(autoBackup.status))
|
||||
}
|
||||
} else if (canImport) {
|
||||
// Nothing to back up, but restore is still possible — don't hide
|
||||
// it behind export eligibility.
|
||||
SectionHeader(stringResource(R.string.calendars_restore_header))
|
||||
HintText(stringResource(R.string.calendars_restore_hint))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_restore_action),
|
||||
position = Position.Alone,
|
||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||
onExportPick = { showExportPicker = true },
|
||||
onEditInterval = { showInterval = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showExportPicker) {
|
||||
ExportCalendarPicker(
|
||||
calendars = exportable,
|
||||
onExport = viewModel::exportBackup,
|
||||
onDismiss = { showExportPicker = false },
|
||||
)
|
||||
// Gated on Ready rather than resetting the flag when the state leaves it:
|
||||
// flipping it here would be a write during composition.
|
||||
(state as? BackupUiState.Ready)?.let { ready ->
|
||||
if (showExportPicker) {
|
||||
ExportCalendarPicker(
|
||||
calendars = ready.exportable,
|
||||
onExport = viewModel::exportBackup,
|
||||
onDismiss = { showExportPicker = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showInterval) {
|
||||
BackupIntervalDialog(
|
||||
@@ -214,6 +179,103 @@ fun BackupScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** The screen's loading state, centred in the scaffold's content column. */
|
||||
@Composable
|
||||
private fun BackupLoading() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
/** Default file name for a one-shot export. */
|
||||
private fun defaultBackupName(): String = "calendula-backup-${LocalDate.now()}.ics"
|
||||
|
||||
/**
|
||||
* The working screen: export (when there are local calendars to export), restore,
|
||||
* and the automatic-backup block. Restore is always on screen — when nothing can
|
||||
* receive the events it says so and routes to the calendar manager rather than
|
||||
* disappearing, which is what #304 reported as an invisible button.
|
||||
*/
|
||||
@Composable
|
||||
private fun BackupContent(
|
||||
exportable: List<CalendarSource>,
|
||||
canImport: Boolean,
|
||||
autoBackup: AutoBackupUiState,
|
||||
viewModel: CalendarsViewModel,
|
||||
onRestore: () -> Unit,
|
||||
onPickFolder: () -> Unit,
|
||||
onExportAll: () -> Unit,
|
||||
onExportPick: () -> Unit,
|
||||
onEditInterval: () -> Unit,
|
||||
) {
|
||||
val manageCalendars = LocalManageCalendars.current
|
||||
// Restore never depends on export eligibility, and never disappears: when no
|
||||
// calendar can receive events it explains that and offers the way to fix it.
|
||||
val restoreSummary = if (canImport) {
|
||||
stringResource(R.string.calendars_restore_hint)
|
||||
} else {
|
||||
stringResource(R.string.calendars_restore_unavailable)
|
||||
}
|
||||
val onRestoreClick = if (canImport) onRestore else (manageCalendars ?: onRestore)
|
||||
|
||||
if (exportable.isEmpty()) {
|
||||
SectionHeader(stringResource(R.string.calendars_restore_header))
|
||||
HintText(restoreSummary)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_restore_action),
|
||||
position = Position.Alone,
|
||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||
onClick = onRestoreClick,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
HintText(stringResource(R.string.calendars_backup_hint))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_backup_action),
|
||||
position = Position.Top,
|
||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||
// A single exportable calendar skips the selector.
|
||||
onClick = if (exportable.size == 1) onExportAll else onExportPick,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_restore_action),
|
||||
summary = restoreSummary,
|
||||
position = Position.Middle,
|
||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||
onClick = onRestoreClick,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup),
|
||||
summary = stringResource(R.string.calendars_auto_backup_hint),
|
||||
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
||||
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
||||
trailing = {
|
||||
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
|
||||
},
|
||||
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
|
||||
)
|
||||
if (autoBackup.enabled) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_folder),
|
||||
summary = rememberFolderName(autoBackup.folderUri)
|
||||
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
||||
position = Position.Middle,
|
||||
onClick = onPickFolder,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = onEditInterval,
|
||||
)
|
||||
HintText(backupStatusText(autoBackup.status))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
||||
* to all selected; the Export action opens the SAF save dialog and hands back
|
||||
@@ -252,7 +314,7 @@ private fun ExportCalendarPicker(
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
val isSelected = calendar.id in selected
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
summary = calendar.description,
|
||||
position = positionOf(index, calendars.size),
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||
|
||||
/**
|
||||
* State of the Backup & restore screen (#69). Three states, because the calendar
|
||||
* list it is derived from has all three: it arrives empty, it can throw, and
|
||||
* "loaded but nothing to offer" is a real outcome the screen used to render as a
|
||||
* blank page (#304).
|
||||
*/
|
||||
sealed interface BackupUiState {
|
||||
data object Loading : BackupUiState
|
||||
data class Failure(val reason: FailureReason) : BackupUiState
|
||||
|
||||
/**
|
||||
* At least one half of the screen works. [exportable] may be empty (restore
|
||||
* only) and [canImport] may be false (export only), but never both — that is
|
||||
* a [Failure].
|
||||
*/
|
||||
data class Ready(
|
||||
val exportable: List<CalendarSource>,
|
||||
val canImport: Boolean,
|
||||
) : BackupUiState
|
||||
}
|
||||
|
||||
/**
|
||||
* What the screen can offer for this calendar list.
|
||||
*
|
||||
* Export covers the app's own local calendars; managed special-dates mirrors are
|
||||
* rebuilt from contacts, so they are excluded. Restore can target anything the
|
||||
* import picker offers ([isEventTarget]).
|
||||
*
|
||||
* A failure is raised only when *neither* is possible — deliberately not
|
||||
* [calendarListFailure] on its own, which would call an all-hidden device a
|
||||
* failure while its local calendars are still perfectly exportable. When both
|
||||
* halves are dead the list itself usually says why (no calendars, everything
|
||||
* switched off); [FailureReason.NoImportTarget] covers the remaining case, where
|
||||
* calendars exist and are visible but every one of them is read-only, managed or
|
||||
* not synced to the device.
|
||||
*/
|
||||
fun backupUiState(calendars: List<CalendarSource>): BackupUiState {
|
||||
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||
val canImport = calendars.any { it.isEventTarget }
|
||||
if (exportable.isEmpty() && !canImport) {
|
||||
return BackupUiState.Failure(
|
||||
calendarListFailure(calendars) ?: FailureReason.NoImportTarget,
|
||||
)
|
||||
}
|
||||
return BackupUiState.Ready(exportable = exportable, canImport = canImport)
|
||||
}
|
||||
@@ -90,6 +90,7 @@ import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||
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.eventInk
|
||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||
@@ -97,11 +98,25 @@ import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||
|
||||
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||
@@ -118,6 +133,7 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||
fun CalendarsScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenBackup: () -> Unit,
|
||||
visible: Boolean = true,
|
||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||
@@ -162,6 +178,7 @@ fun CalendarsScreen(
|
||||
onConsumeError = viewModel::consumeError,
|
||||
onOpenBackup = onOpenBackup,
|
||||
onBack = onBack,
|
||||
visible = visible,
|
||||
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||
onSetVisible = viewModel::setCalendarVisible,
|
||||
@@ -178,6 +195,7 @@ private fun CalendarsList(
|
||||
onConsumeError: () -> Unit,
|
||||
onOpenBackup: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean,
|
||||
onAdd: () -> Unit,
|
||||
onEdit: (CalendarSource) -> Unit,
|
||||
onSetVisible: (Long, Boolean) -> Unit,
|
||||
@@ -203,6 +221,7 @@ private fun CalendarsList(
|
||||
onBack = onBack,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
// What the per-calendar / per-account switches below actually do.
|
||||
HintText(stringResource(R.string.calendars_visibility_hint))
|
||||
@@ -231,7 +250,7 @@ private fun CalendarsList(
|
||||
local.forEachIndexed { index, calendar ->
|
||||
val disabled = !calendar.isVisibleInSystem
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
summary = calendarRowSummary(calendar),
|
||||
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
@@ -239,7 +258,7 @@ private fun CalendarsList(
|
||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||
trailing = {
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName,
|
||||
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
enabled = !disabled,
|
||||
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
||||
)
|
||||
@@ -316,7 +335,7 @@ private fun CalendarsList(
|
||||
ordered.forEachIndexed { index, calendar ->
|
||||
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
summary = calendarRowSummary(calendar),
|
||||
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
@@ -325,7 +344,7 @@ private fun CalendarsList(
|
||||
trailing = if (calendar.hasVisibilitySwitch) {
|
||||
{
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName,
|
||||
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
enabled = calendar.isVisibleInSystem,
|
||||
onToggle = { enabled ->
|
||||
onSetVisible(calendar.id, enabled)
|
||||
@@ -343,9 +362,15 @@ private fun CalendarsList(
|
||||
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private const val SWATCHES_PER_ROW = 6
|
||||
|
||||
/**
|
||||
* Create or edit a local calendar: a name field over the palette of calendar
|
||||
* colours, on the family's full-screen sheet with the commit in its title bar.
|
||||
* The same sheet Agendula edits a task list in, so the two read as one product.
|
||||
*/
|
||||
@Composable
|
||||
private fun CalendarEditor(
|
||||
internal fun CalendarEditor(
|
||||
sessionKey: Int,
|
||||
isNew: Boolean,
|
||||
initialName: String,
|
||||
@@ -360,121 +385,50 @@ private fun CalendarEditor(
|
||||
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
||||
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
|
||||
var confirmDelete by remember { mutableStateOf(false) }
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.predictiveBack(onBack = onClose)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (isNew) R.string.calendars_new_title
|
||||
else R.string.calendars_edit_title,
|
||||
),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.event_edit_close),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (!isNew) {
|
||||
// Disabled rather than hidden while the special-dates
|
||||
// sync owns this calendar; the card below says why.
|
||||
IconButton(
|
||||
onClick = { confirmDelete = true },
|
||||
enabled = !deleteLocked,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.event_detail_delete),
|
||||
tint = if (deleteLocked) {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
// Filled save button, matching the event editor's top bar.
|
||||
Button(
|
||||
onClick = {
|
||||
onSave(name.trim(), color, description.trim().ifEmpty { null })
|
||||
},
|
||||
enabled = name.isNotBlank(),
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.event_edit_save))
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
val valid = name.isNotBlank()
|
||||
val commit = { if (valid) onSave(name.trim(), color, description.trim().ifEmpty { null }) }
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(
|
||||
if (isNew) R.string.calendars_new_title else R.string.calendars_edit_title,
|
||||
),
|
||||
onDismiss = onClose,
|
||||
predictiveBack = true,
|
||||
actions = {
|
||||
Button(
|
||||
onClick = commit,
|
||||
enabled = valid,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
) { Text(stringResource(R.string.event_edit_save)) }
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (deleteLocked) {
|
||||
EditorCard(
|
||||
icon = Icons.Default.Info,
|
||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
iconAtTop = true,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_managed_delete_locked),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) {
|
||||
InlineTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
placeholder = stringResource(R.string.calendars_name_label),
|
||||
textStyle = MaterialTheme.typography.titleLarge,
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
)
|
||||
}
|
||||
EditorCard(
|
||||
icon = Icons.Default.Palette,
|
||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
iconAtTop = true,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_color_label),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
ColorSwatchRow(
|
||||
colors = CalendarColorPalette.all,
|
||||
selected = color,
|
||||
onSelect = { color = it },
|
||||
dark = dark,
|
||||
)
|
||||
}
|
||||
EditorCard(
|
||||
icon = Icons.AutoMirrored.Filled.Notes,
|
||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
iconAtTop = true,
|
||||
) {
|
||||
) {
|
||||
if (deleteLocked) {
|
||||
ManagedNote()
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
CalendarNameField(
|
||||
name = name,
|
||||
color = color,
|
||||
focusRequester = focusRequester,
|
||||
onNameChange = { name = it },
|
||||
onImeAction = commit,
|
||||
)
|
||||
// Inside the picker: it is a Dialog, so a request made from the caller's
|
||||
// composition can run before this field's node exists.
|
||||
// A new calendar opens with the keyboard up: naming it is the whole task.
|
||||
LaunchedEffect(isNew) { if (isNew) focusRequester.requestFocus() }
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
EditorSectionLabel(stringResource(R.string.calendars_color_label))
|
||||
CalendarColorGrid(selected = color, onSelect = { color = it })
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
EditorSectionLabel(stringResource(R.string.calendars_description_label))
|
||||
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
Box(Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(16.dp)) {
|
||||
InlineTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
@@ -485,20 +439,21 @@ private fun CalendarEditor(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isNew && !deleteLocked) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
DeleteCalendarRow(onClick = { confirmDelete = true })
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
if (confirmDelete) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmDelete = false },
|
||||
title = { Text(stringResource(R.string.calendars_delete_confirm_title)) },
|
||||
text = {
|
||||
Text(stringResource(R.string.calendars_delete_confirm_message, initialName))
|
||||
},
|
||||
text = { Text(stringResource(R.string.calendars_delete_confirm_message, initialName)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
confirmDelete = false
|
||||
onDelete()
|
||||
}) {
|
||||
TextButton(onClick = { confirmDelete = false; onDelete() }) {
|
||||
Text(
|
||||
stringResource(R.string.event_detail_delete),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
@@ -514,6 +469,205 @@ private fun CalendarEditor(
|
||||
}
|
||||
}
|
||||
|
||||
/** The name, with the chosen colour beside it so the two read as one thing. */
|
||||
@Composable
|
||||
private fun CalendarNameField(
|
||||
name: String,
|
||||
color: Int,
|
||||
focusRequester: FocusRequester,
|
||||
onNameChange: (String) -> Unit,
|
||||
onImeAction: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CalendarMonth,
|
||||
contentDescription = null,
|
||||
tint = eventAccent(color, dark, soften),
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
InlineTextField(
|
||||
value = name,
|
||||
onValueChange = onNameChange,
|
||||
placeholder = stringResource(R.string.calendars_name_label),
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
imeAction = ImeAction.Done,
|
||||
onImeAction = onImeAction,
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The palette as two rows of round swatches; the chosen one carries a check.
|
||||
*
|
||||
* Drawn through the app's own tone pass rather than raw, so a swatch is the
|
||||
* colour the calendar's events will actually appear in — the user's "soften
|
||||
* colours" preference included.
|
||||
*/
|
||||
@Composable
|
||||
private fun CalendarColorGrid(selected: Int, onSelect: (Int) -> Unit) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp, vertical = 16.dp)
|
||||
.selectableGroup(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
CalendarColorPalette.all.chunked(SWATCHES_PER_ROW).forEach { row ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
row.forEach { swatch ->
|
||||
ColorSwatch(
|
||||
fill = eventFill(swatch, dark, soften),
|
||||
label = stringResource(swatchLabel(swatch)),
|
||||
selected = swatch == selected,
|
||||
onClick = { onSelect(swatch) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
// Keeps a short final row's swatches the size a full row's
|
||||
// are, rather than stretching them across the width.
|
||||
repeat(SWATCHES_PER_ROW - row.size) { Spacer(Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColorSwatch(
|
||||
fill: Color,
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.aspectRatio(1f)
|
||||
.clip(CircleShape)
|
||||
.background(fill)
|
||||
.then(
|
||||
if (selected) {
|
||||
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
// selectable, not clickable: the swatch carries its chosen state in
|
||||
// semantics too, so the check is not the only cue.
|
||||
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
|
||||
.semantics { contentDescription = label },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (selected) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = eventInk(fill, alpha = 0.7f),
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Names the swatch for a screen reader — twelve circles are otherwise one label. */
|
||||
@StringRes
|
||||
private fun swatchLabel(argb: Int): Int = when (argb) {
|
||||
CalendarColorPalette.Mauve -> R.string.color_name_mauve
|
||||
CalendarColorPalette.Red -> R.string.color_name_red
|
||||
CalendarColorPalette.Orange -> R.string.color_name_orange
|
||||
CalendarColorPalette.Amber -> R.string.color_name_amber
|
||||
CalendarColorPalette.Olive -> R.string.color_name_olive
|
||||
CalendarColorPalette.Green -> R.string.color_name_green
|
||||
CalendarColorPalette.Teal -> R.string.color_name_teal
|
||||
CalendarColorPalette.Cyan -> R.string.color_name_cyan
|
||||
CalendarColorPalette.Blue -> R.string.color_name_blue
|
||||
CalendarColorPalette.Indigo -> R.string.color_name_indigo
|
||||
CalendarColorPalette.Purple -> R.string.color_name_purple
|
||||
else -> R.string.color_name_pink
|
||||
}
|
||||
|
||||
/** Said in the sheet rather than on a disabled button: the sync owns this one. */
|
||||
@Composable
|
||||
private fun ManagedNote() {
|
||||
GroupedSurface(
|
||||
position = Position.Alone,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Info, contentDescription = null)
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_managed_delete_locked),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteCalendarRow(onClick: () -> Unit) {
|
||||
GroupedSurface(
|
||||
position = Position.Alone,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
onClick = onClick,
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(horizontal = 20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.event_detail_delete),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditorSectionLabel(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The row's supporting line: the states that make this calendar behave unlike a
|
||||
* plain writable one (#76), then its own description.
|
||||
@@ -556,36 +710,6 @@ private fun EnableSwitch(
|
||||
private fun dimIf(disabled: Boolean): Modifier =
|
||||
if (disabled) Modifier.alpha(0.38f) else Modifier
|
||||
|
||||
/** Tonal field card matching the event editor's design (icon + content). */
|
||||
@Composable
|
||||
private fun EditorCard(
|
||||
icon: ImageVector,
|
||||
iconTint: Color,
|
||||
iconAtTop: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = iconTint,
|
||||
modifier = Modifier
|
||||
.padding(top = if (iconAtTop) 2.dp else 0.dp)
|
||||
.size(24.dp),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One collapsible calendar group rendered as a connected card. The header row is
|
||||
|
||||
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -24,6 +25,7 @@ import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -56,6 +58,22 @@ class CalendarsViewModel @Inject constructor(
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* The Backup & restore screen's own view of that list, with the loading and
|
||||
* failure states [calendars] flattens away — it starts empty and catches to
|
||||
* empty, which that screen used to render as a blank page (#304).
|
||||
*/
|
||||
val backupState: StateFlow<BackupUiState> =
|
||||
repository.calendars()
|
||||
.map { backupUiState(it) }
|
||||
.catch { emit(BackupUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = BackupUiState.Loading,
|
||||
)
|
||||
|
||||
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
||||
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
||||
settingsPrefs.autoBackupEnabled,
|
||||
|
||||
@@ -56,12 +56,14 @@ fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
|
||||
* account comes from when another account shares the name (#77).
|
||||
*/
|
||||
@Composable
|
||||
fun accountGroupTitle(group: CalendarAccountGroup): String =
|
||||
if (!group.ambiguous) {
|
||||
group.label
|
||||
fun accountGroupTitle(group: CalendarAccountGroup): String {
|
||||
val label = group.label.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||
return if (!group.ambiguous) {
|
||||
label
|
||||
} else {
|
||||
stringResource(R.string.calendars_account_from_source, group.label, sourceAppName(group.accountType))
|
||||
stringResource(R.string.calendars_account_from_source, label, sourceAppName(group.accountType))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The human name of the app backing [accountType], falling back to the raw
|
||||
|
||||
@@ -3,6 +3,7 @@ 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.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -13,15 +14,19 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.ParentDataModifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Gap a timed block leaves to its neighbours in the column. */
|
||||
val BLOCK_OUTER_INSET = 1.dp
|
||||
@@ -32,6 +37,68 @@ val BLOCK_TEXT_PADDING = 4.dp
|
||||
/** The same, above and below — what a block's height has to pay before any text. */
|
||||
val BLOCK_TEXT_INSET = 2.dp
|
||||
|
||||
/**
|
||||
* What a timed block of a given height has to spend on text, and what each line
|
||||
* of it costs.
|
||||
*/
|
||||
@Immutable
|
||||
data class BlockTextMetrics(
|
||||
/** Padding above and below the text — see [rememberBlockTextMetrics]. */
|
||||
val inset: Dp,
|
||||
/** Height left for text once [inset] is paid at both edges. */
|
||||
val available: Dp,
|
||||
/** What the first line of a title draws in. */
|
||||
val titleLine: Dp,
|
||||
/** What every title line after the first adds. */
|
||||
val titleLeading: Dp,
|
||||
/** What the time label's one line draws in. */
|
||||
val timeLine: Dp,
|
||||
) {
|
||||
/** Whether the block can draw a title at all. */
|
||||
val fitsTitle: Boolean get() = available >= titleLine
|
||||
|
||||
/** Height a title of [lines] lines occupies. */
|
||||
fun titleHeight(lines: Int): Dp =
|
||||
if (lines <= 0) 0.dp else titleLine + titleLeading * (lines - 1)
|
||||
|
||||
/** Title lines that fit [within], which may be none. */
|
||||
fun titleBudget(within: Dp): Int =
|
||||
if (within < titleLine) 0 else 1 + ((within - titleLine) / titleLeading).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* The vertical padding a block [height] tall can afford around a title line of
|
||||
* [titleLine].
|
||||
*
|
||||
* The inset is what the block gives up first: breathing room is worth having
|
||||
* where there is room to breathe, but on a block down to its last few pixels a
|
||||
* bare colour chip where a label would have fit reads as a rendering fault. It
|
||||
* tapers rather than snapping, so a pinch closes the gap gradually instead of
|
||||
* dropping it in one frame (#289).
|
||||
*/
|
||||
internal fun blockTextInset(height: Dp, titleLine: Dp): Dp =
|
||||
minOf(BLOCK_TEXT_INSET, (height - titleLine) / 2).coerceAtLeast(0.dp)
|
||||
|
||||
/** Text metrics for a timed block [height] tall. */
|
||||
@Composable
|
||||
fun rememberBlockTextMetrics(height: Dp): BlockTextMetrics {
|
||||
val titleStyle = MaterialTheme.typography.labelMedium
|
||||
val titleLine = rememberTrimmedLineHeight(titleStyle)
|
||||
// Packed, so a second line costs what the first did rather than a whole
|
||||
// Material line box — the gap between two lines of a wrapped title is the
|
||||
// one place a block pays that leading twice (#190).
|
||||
val titleLeading = titleLine
|
||||
val timeLine = rememberTrimmedLineHeight(MaterialTheme.typography.labelSmall.asEventTime())
|
||||
val inset = blockTextInset(height, titleLine)
|
||||
return BlockTextMetrics(
|
||||
inset = inset,
|
||||
available = height - inset * 2,
|
||||
titleLine = titleLine,
|
||||
titleLeading = titleLeading,
|
||||
timeLine = timeLine,
|
||||
)
|
||||
}
|
||||
|
||||
/** Most lines a time label may wrap over before it is worth more than a title line. */
|
||||
const val MAX_TIME_LINES = 2
|
||||
|
||||
@@ -70,9 +137,9 @@ fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int
|
||||
*/
|
||||
@Composable
|
||||
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
|
||||
val timeLineHeight = with(LocalDensity.current) {
|
||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||
}
|
||||
val timeLineHeight = rememberTrimmedLineHeight(
|
||||
MaterialTheme.typography.labelSmall.asEventTime(),
|
||||
)
|
||||
return if (spare >= timeLineHeight) {
|
||||
blockTextLines(
|
||||
text = label,
|
||||
@@ -108,8 +175,10 @@ fun BlockTitle(
|
||||
Text(
|
||||
text = title,
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
|
||||
style = rememberPackedLines(
|
||||
MaterialTheme.typography.labelMedium
|
||||
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
|
||||
),
|
||||
maxLines = maxLines,
|
||||
overflow = overflow.overflow,
|
||||
softWrap = overflow.softWrap,
|
||||
@@ -141,6 +210,8 @@ fun BlockTimeLabel(
|
||||
MaterialTheme.motionScheme.fastEffectsSpec()
|
||||
}
|
||||
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
|
||||
// Regular weight against the title's medium above it (#219).
|
||||
val style = rememberPackedLines(MaterialTheme.typography.labelSmall.asEventTime())
|
||||
Crossfade(
|
||||
targetState = label,
|
||||
animationSpec = spec,
|
||||
@@ -149,8 +220,7 @@ fun BlockTimeLabel(
|
||||
) { text ->
|
||||
Text(
|
||||
text = text,
|
||||
// Regular weight against the title's medium above it (#219).
|
||||
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
||||
style = style,
|
||||
maxLines = maxLines,
|
||||
overflow = overflow.overflow,
|
||||
softWrap = overflow.softWrap,
|
||||
@@ -159,9 +229,13 @@ fun BlockTimeLabel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a timed block sits in its column, after tweening. */
|
||||
/**
|
||||
* Where a timed block sits in its column, after tweening: across as shares of
|
||||
* the column's width, so a column resizing carries its blocks with it (#184),
|
||||
* down in the timeline's own units.
|
||||
*/
|
||||
@Immutable
|
||||
data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
|
||||
data class BlockPlacement(val xFraction: Float, val widthFraction: Float, val y: Dp, val height: Dp)
|
||||
|
||||
/**
|
||||
* A timed block's placement, tweened rather than jumped. Continuity comes from
|
||||
@@ -174,17 +248,49 @@ data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
|
||||
*/
|
||||
@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()
|
||||
fun animatedBlockPlacement(xFraction: Float, widthFraction: Float, y: Dp, height: Dp): BlockPlacement {
|
||||
val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching
|
||||
val dpSpec: FiniteAnimationSpec<Dp> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
val shareSpec: FiniteAnimationSpec<Float> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x")
|
||||
val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width")
|
||||
val animatedY by animateDpAsState(y, dpSpec, label = "block-y")
|
||||
val animatedHeight by animateDpAsState(height, dpSpec, label = "block-height")
|
||||
return BlockPlacement(animatedX, animatedWidth, animatedY, animatedHeight)
|
||||
}
|
||||
|
||||
/** Seats a block across its [placement]'s share of a [LaneColumn]. */
|
||||
fun Modifier.laneSlot(placement: BlockPlacement): Modifier =
|
||||
then(LaneSlot(placement.xFraction, placement.widthFraction))
|
||||
|
||||
private data class LaneSlot(val x: Float, val width: Float) : ParentDataModifier {
|
||||
override fun Density.modifyParentData(parentData: Any?): Any = this@LaneSlot
|
||||
}
|
||||
|
||||
/**
|
||||
* A day column's blocks, each measured across the share of the column its
|
||||
* [laneSlot] gives it; anything else fills the column. The shares are applied
|
||||
* at layout time, so the column can change width every frame without its
|
||||
* blocks recomposing.
|
||||
*/
|
||||
@Composable
|
||||
fun LaneColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||
Layout(content = content, modifier = modifier) { measurables, constraints ->
|
||||
val width = constraints.maxWidth
|
||||
val loose = constraints.copy(minWidth = 0, minHeight = 0)
|
||||
val placed = measurables.map { measurable ->
|
||||
val slot = measurable.parentData as? LaneSlot
|
||||
if (slot == null) {
|
||||
measurable.measure(loose) to 0
|
||||
} else {
|
||||
val left = (slot.x * width).roundToInt()
|
||||
val right = ((slot.x + slot.width) * width).roundToInt()
|
||||
val laneWidth = (right - left).coerceAtLeast(0)
|
||||
measurable.measure(loose.copy(minWidth = laneWidth, maxWidth = laneWidth)) to left
|
||||
}
|
||||
}
|
||||
layout(width, constraints.maxHeight) {
|
||||
placed.forEach { (placeable, x) -> placeable.placeRelative(x, 0) }
|
||||
}
|
||||
}
|
||||
val animatedX by animateDpAsState(x, spec, label = "block-x")
|
||||
val animatedY by animateDpAsState(y, spec, label = "block-y")
|
||||
val animatedWidth by animateDpAsState(width, spec, label = "block-width")
|
||||
val animatedHeight by animateDpAsState(height, spec, label = "block-height")
|
||||
return BlockPlacement(animatedX, animatedY, animatedWidth, animatedHeight)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* What a calendar screen shows in the top bar, the FAB and the drawer, which the
|
||||
* host draws once for all of them (#184): switching screens changes what they
|
||||
* say rather than swapping one bar for another.
|
||||
*
|
||||
* @param titleDate the date the title's picker opens on, or null for a title
|
||||
* that is only a label.
|
||||
* @param drawerDate the date the drawer's picker opens on.
|
||||
* @param awayFromToday whether "today" is worth offering.
|
||||
*/
|
||||
@Immutable
|
||||
data class ScreenChrome(
|
||||
val title: String,
|
||||
val shortTitle: String = title,
|
||||
val titleDate: LocalDate?,
|
||||
val drawerDate: LocalDate,
|
||||
val awayFromToday: Boolean,
|
||||
val todayText: String,
|
||||
)
|
||||
|
||||
/** What the chrome does for the screen on show; read when used, so not observed. */
|
||||
class ScreenActions(
|
||||
val onToday: () -> Unit,
|
||||
val onJumpToDate: (LocalDate) -> Unit,
|
||||
val onCreate: () -> Unit,
|
||||
)
|
||||
|
||||
/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */
|
||||
@Stable
|
||||
class ChromeHost {
|
||||
var chrome by mutableStateOf<ScreenChrome?>(null)
|
||||
private set
|
||||
|
||||
var actions: ScreenActions? = null
|
||||
private set
|
||||
|
||||
internal fun publish(chrome: ScreenChrome, actions: ScreenActions) {
|
||||
this.chrome = chrome
|
||||
this.actions = actions
|
||||
}
|
||||
}
|
||||
|
||||
/** The host's chrome, and whether the screen reading it is the one on show rather than one leaving. */
|
||||
class ChromeSlot(val host: ChromeHost, val current: Boolean)
|
||||
|
||||
val LocalChromeSlot = staticCompositionLocalOf<ChromeSlot?> { null }
|
||||
|
||||
/** Hands [chrome] and [actions] to the host, while this screen is the one on show. */
|
||||
@Composable
|
||||
fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) {
|
||||
val slot = LocalChromeSlot.current ?: return
|
||||
if (slot.current) SideEffect { slot.host.publish(chrome, actions) }
|
||||
}
|
||||
|
||||
/** The top bar every calendar screen shares. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CalendarTopBar(
|
||||
host: ChromeHost,
|
||||
selectedView: CalendarView,
|
||||
quickSwitchViews: List<CalendarView>,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
) {
|
||||
val chrome = host.chrome
|
||||
TopAppBar(
|
||||
title = {
|
||||
if (chrome != null) {
|
||||
CalendarTitleButton(
|
||||
title = chrome.title,
|
||||
currentDate = chrome.titleDate,
|
||||
onJumpToDate = { host.actions?.onJumpToDate?.invoke(it) },
|
||||
shortTitle = chrome.shortTitle,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(R.string.month_open_menu),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TodayAction(show = showTodayButton, onToday = { host.actions?.onToday?.invoke() })
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = stringResource(R.string.search_action),
|
||||
)
|
||||
}
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
cycle = quickSwitchViews,
|
||||
onCycle = onCycleView,
|
||||
)
|
||||
},
|
||||
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
||||
// it, but here the bar meets the header on the same surface and the
|
||||
// tint is what makes that seam look like a separate block (#186).
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** The FAB column every calendar screen shares. */
|
||||
@Composable
|
||||
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean) {
|
||||
val chrome = host.chrome ?: return
|
||||
CalendarFabColumn(
|
||||
todayVisible = chrome.awayFromToday && !todayInToolbar,
|
||||
todayText = chrome.todayText,
|
||||
onToday = { host.actions?.onToday?.invoke() },
|
||||
onCreate = { host.actions?.onCreate?.invoke() },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A calendar screen's body under the shared chrome, on the ground the screen's
|
||||
* own Scaffold used to give it; the host's Scaffold has already made room for
|
||||
* the bars, so [content] gets no padding of its own.
|
||||
*/
|
||||
@Composable
|
||||
fun ScreenBody(modifier: Modifier = Modifier, content: @Composable (PaddingValues) -> Unit) {
|
||||
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
content(PaddingValues())
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ fun CalendarDrawer(
|
||||
DrawerSectionHeader(stringResource(R.string.view_section))
|
||||
viewOrder.forEachIndexed { index, view ->
|
||||
GroupedRow(
|
||||
title = stringResource(view.labelRes),
|
||||
title = view.label(),
|
||||
position = positionOf(index, viewOrder.size),
|
||||
selected = view == currentView,
|
||||
minHeight = 56.dp,
|
||||
|
||||
@@ -1,17 +1,40 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.Settings
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.CalendarMonth
|
||||
import androidx.compose.material.icons.outlined.EditOff
|
||||
import androidx.compose.material.icons.outlined.ErrorOutline
|
||||
import androidx.compose.material.icons.outlined.EventBusy
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.SyncProblem
|
||||
import androidx.compose.material.icons.outlined.VisibilityOff
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -20,37 +43,168 @@ import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
|
||||
/**
|
||||
* Full-screen failure state shared by every calendar screen (spec §7).
|
||||
* One explanation line + one recovery action, never a toast.
|
||||
* A tonal icon, one headline, one supporting line and one recovery action —
|
||||
* never a toast.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
|
||||
val titleRes = when (reason) {
|
||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
||||
FailureReason.ProviderUnavailable -> R.string.state_failure_provider
|
||||
FailureReason.Unknown,
|
||||
FailureReason.EventNotFound -> R.string.state_failure_unknown
|
||||
}
|
||||
val actionRes = when (reason) {
|
||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action
|
||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission_action
|
||||
else -> R.string.state_retry
|
||||
fun CalendarFailure(
|
||||
reason: FailureReason,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val manageCalendars = LocalManageCalendars.current
|
||||
val copy = failureCopy(reason)
|
||||
val onAction: () -> Unit = when (reason) {
|
||||
FailureReason.NoCalendarsConfigured -> {
|
||||
{ context.startCalendarSetup() }
|
||||
}
|
||||
FailureReason.AllCalendarsHidden,
|
||||
FailureReason.NoImportTarget,
|
||||
-> manageCalendars ?: onRetry
|
||||
else -> onRetry
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
.padding(horizontal = 32.dp, vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
FailureIcon(icon = copy.icon, isError = copy.isError)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = stringResource(titleRes),
|
||||
text = stringResource(copy.title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH),
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
FilledTonalButton(onClick = onRetry) {
|
||||
Text(stringResource(actionRes))
|
||||
copy.body?.let { body ->
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(32.dp))
|
||||
FilledTonalButton(onClick = onAction) {
|
||||
Text(stringResource(copy.action))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The state's mark: the reason's icon in a tonal circle above the headline. */
|
||||
@Composable
|
||||
private fun FailureIcon(icon: ImageVector, isError: Boolean) {
|
||||
val container: Color
|
||||
val content: Color
|
||||
if (isError) {
|
||||
container = MaterialTheme.colorScheme.errorContainer
|
||||
content = MaterialTheme.colorScheme.onErrorContainer
|
||||
} else {
|
||||
container = MaterialTheme.colorScheme.secondaryContainer
|
||||
content = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.background(container, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = content,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a reason says and offers. Headline and supporting line are separate so
|
||||
* the explanation reads as body text instead of a headline that runs over three
|
||||
* lines; [isError] tints the mark for the two reasons that are an actual fault
|
||||
* rather than a calendar the user can switch back on.
|
||||
*/
|
||||
private data class FailureCopy(
|
||||
val icon: ImageVector,
|
||||
@StringRes val title: Int,
|
||||
@StringRes val body: Int?,
|
||||
@StringRes val action: Int,
|
||||
val isError: Boolean = false,
|
||||
)
|
||||
|
||||
private fun failureCopy(reason: FailureReason): FailureCopy = when (reason) {
|
||||
FailureReason.PermissionRevoked -> FailureCopy(
|
||||
icon = Icons.Outlined.Lock,
|
||||
title = R.string.state_failure_permission,
|
||||
body = R.string.state_failure_permission_body,
|
||||
action = R.string.state_failure_permission_action,
|
||||
)
|
||||
FailureReason.NoCalendarsConfigured -> FailureCopy(
|
||||
icon = Icons.Outlined.CalendarMonth,
|
||||
title = R.string.state_failure_no_calendars,
|
||||
body = R.string.state_failure_no_calendars_body,
|
||||
action = R.string.state_failure_no_calendars_action,
|
||||
)
|
||||
FailureReason.AllCalendarsHidden -> FailureCopy(
|
||||
icon = Icons.Outlined.VisibilityOff,
|
||||
title = R.string.state_failure_all_hidden,
|
||||
body = R.string.state_failure_all_hidden_body,
|
||||
action = R.string.state_failure_all_hidden_action,
|
||||
)
|
||||
FailureReason.NoImportTarget -> FailureCopy(
|
||||
icon = Icons.Outlined.EditOff,
|
||||
title = R.string.state_failure_no_import_target,
|
||||
body = R.string.state_failure_no_import_target_body,
|
||||
action = R.string.state_failure_all_hidden_action,
|
||||
)
|
||||
FailureReason.EventNotFound -> FailureCopy(
|
||||
icon = Icons.Outlined.EventBusy,
|
||||
title = R.string.state_failure_event_not_found,
|
||||
body = R.string.state_failure_event_not_found_body,
|
||||
action = R.string.state_retry,
|
||||
)
|
||||
FailureReason.ProviderUnavailable -> FailureCopy(
|
||||
icon = Icons.Outlined.SyncProblem,
|
||||
title = R.string.state_failure_provider,
|
||||
body = R.string.state_failure_provider_body,
|
||||
action = R.string.state_retry,
|
||||
isError = true,
|
||||
)
|
||||
FailureReason.Unknown -> FailureCopy(
|
||||
icon = Icons.Outlined.ErrorOutline,
|
||||
title = R.string.state_failure_unknown,
|
||||
body = null,
|
||||
action = R.string.state_retry,
|
||||
isError = true,
|
||||
)
|
||||
}
|
||||
|
||||
/** Keeps the headline and its supporting line at a readable measure. */
|
||||
private val TEXT_MAX_WIDTH = 320.dp
|
||||
|
||||
/**
|
||||
* Opens Settings → Calendars, the only screen that can switch a calendar back
|
||||
* on. Null outside the calendar host — screens without it never raise the
|
||||
* reason that needs it, and fall back to their own retry.
|
||||
*/
|
||||
val LocalManageCalendars = compositionLocalOf<(() -> Unit)?> { null }
|
||||
|
||||
/**
|
||||
* Open the system screen for putting a calendar on the device: the account
|
||||
* chooser filtered to calendar providers, falling back to account settings
|
||||
* where no chooser resolves. Both are system components, so nothing here needs
|
||||
* a `<queries>` entry.
|
||||
*/
|
||||
private fun Context.startCalendarSetup() {
|
||||
val intents = listOf(
|
||||
Intent(Settings.ACTION_ADD_ACCOUNT)
|
||||
.putExtra(Settings.EXTRA_AUTHORITIES, arrayOf(CalendarContract.AUTHORITY)),
|
||||
Intent(Settings.ACTION_SYNC_SETTINGS),
|
||||
)
|
||||
intents.firstOrNull { runCatching { startActivity(it) }.isSuccess }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlin.math.floor
|
||||
|
||||
/**
|
||||
* One height for every page of something that varies from page to page — the
|
||||
* timelines' all-day strip, the split grid's rows — blended between the two
|
||||
* pages a swipe sits between, so what sits below it moves with the finger rather
|
||||
* than jumping on settle. It follows the swipe directly and only springs when a
|
||||
* page's own height changes at rest. [fallback] stands in until the pages have
|
||||
* reported theirs, so it doesn't open from zero.
|
||||
*
|
||||
* @param heights each composed page's own height, by page index.
|
||||
*/
|
||||
@Composable
|
||||
fun pagedHeight(pagerState: PagerState, heights: Map<Int, Dp>, fallback: Dp): Dp {
|
||||
val currentFallback by rememberUpdatedState(fallback)
|
||||
val target by remember(pagerState, heights) {
|
||||
derivedStateOf {
|
||||
val position = pagerState.currentPage + pagerState.currentPageOffsetFraction
|
||||
val from = floor(position).toInt()
|
||||
val a = heights[from]
|
||||
val b = heights[from + 1]
|
||||
lerp(a ?: b ?: currentFallback, b ?: a ?: currentFallback, position - from)
|
||||
}
|
||||
}
|
||||
val height = remember { Animatable(target, Dp.VectorConverter) }
|
||||
LaunchedEffect(height) {
|
||||
snapshotFlow { target to pagerState.isScrollInProgress }.collectLatest { (h, scrolling) ->
|
||||
if (scrolling) height.snapTo(h) else height.animateTo(h)
|
||||
}
|
||||
}
|
||||
return height.value
|
||||
}
|
||||
|
||||
/** A deliberate jump (Today, jump-to-date): animated, or snapped under reduced motion. */
|
||||
suspend fun PagerState.jumpToPage(
|
||||
page: Int,
|
||||
reduceMotion: Boolean,
|
||||
spec: FiniteAnimationSpec<Float>,
|
||||
) {
|
||||
if (reduceMotion) scrollToPage(page) else animateScrollToPage(page, animationSpec = spec)
|
||||
}
|
||||
@@ -116,7 +116,7 @@ private fun CalendarPickerGroup(
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
val isSelected = calendar.id == selectedId
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
|
||||
selected = isSelected,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Drag distance that turns a calendar page, shared by the month, week and day
|
||||
* views so all three answer a swipe at the same point.
|
||||
*
|
||||
* It was 6dp once, which is inside the distance a tap wanders: brushing the grid
|
||||
* changed the month, and a page that turns on an unintended gesture reads as the
|
||||
* animation misfiring rather than as the gesture being over-eager.
|
||||
*/
|
||||
val CALENDAR_SWIPE_THRESHOLD = 24.dp
|
||||
|
||||
/**
|
||||
* The whole-page horizontal swipe: one page per gesture, committed **the moment
|
||||
* the drag clears [CALENDAR_SWIPE_THRESHOLD]** rather than when the finger lifts.
|
||||
*
|
||||
* Waiting for the lift meant the page sat still under a finger that had already
|
||||
* travelled far enough to ask for it, and the answer only arrived once you let
|
||||
* go — which reads as the view being slow rather than as a deliberate commit.
|
||||
* Firing on the threshold is what makes the gesture feel like it is being
|
||||
* followed. The trade is that a drag can no longer be taken back by dragging the
|
||||
* other way; in practice, once you have moved 24dp deliberately you meant it, and
|
||||
* the page you land on is one swipe back.
|
||||
*
|
||||
* Deliberately **horizontal-only**. The week and day timelines scroll vertically
|
||||
* underneath this, and a two-dimensional detector here would claim those drags
|
||||
* before the inner scroll ever saw them. As it is, a horizontal drag crosses this
|
||||
* detector's slop while a vertical one is consumed below, and the two coexist.
|
||||
* (The month view's split style needs a vertical axis as well, so it keeps its
|
||||
* own axis-locking detector rather than using this.)
|
||||
*/
|
||||
@Composable
|
||||
fun rememberCalendarPageSwipe(
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
): Modifier {
|
||||
val threshold = with(LocalDensity.current) { CALENDAR_SWIPE_THRESHOLD.toPx() }
|
||||
return Modifier.pointerInput(onSwipeNext, onSwipePrev) {
|
||||
var accum = 0f
|
||||
// One page per gesture: without this a long drag would keep re-firing
|
||||
// every time the accumulator crossed the threshold again.
|
||||
var fired = false
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = {
|
||||
accum = 0f
|
||||
fired = false
|
||||
},
|
||||
onDragEnd = {
|
||||
accum = 0f
|
||||
fired = false
|
||||
},
|
||||
onDragCancel = {
|
||||
accum = 0f
|
||||
fired = false
|
||||
},
|
||||
onHorizontalDrag = { _, drag ->
|
||||
accum += drag
|
||||
if (!fired) {
|
||||
val commit = when {
|
||||
accum < -threshold -> onSwipeNext
|
||||
accum > threshold -> onSwipePrev
|
||||
else -> null
|
||||
}
|
||||
if (commit != null) {
|
||||
fired = true
|
||||
commit()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.LineHeightStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
/**
|
||||
* The calendar surfaces' line-height treatment. Material wraps an 11sp glyph in
|
||||
* a 16sp line box — room a label wants when it stands on its own, and close to
|
||||
* a fifth of an event row when it doesn't. The outer edges only, so a wrapped
|
||||
* title keeps its interior line spacing (#190).
|
||||
*
|
||||
* Trimmed rather than set to a smaller line height: the font picker can load a
|
||||
* serif, a monospace or a file of the user's own, and a line height under a
|
||||
* face's own ascent and descent overlaps its lines. There is nothing to trim
|
||||
* below that, so this is safe whatever font is chosen.
|
||||
*/
|
||||
private val TrimmedLines = LineHeightStyle(
|
||||
alignment = LineHeightStyle.Alignment.Center,
|
||||
trim = LineHeightStyle.Trim.Both,
|
||||
)
|
||||
|
||||
/** Ascenders and descenders both, so a line is measured at its full extent. */
|
||||
private const val LINE_SAMPLE = "Ag"
|
||||
|
||||
/** [this] with Material's outer leading trimmed — see [TrimmedLines]. */
|
||||
fun TextStyle.trimmedLines(): TextStyle = copy(lineHeightStyle = TrimmedLines)
|
||||
|
||||
/**
|
||||
* [style] with its wrapped lines packed onto the face's own extent instead of
|
||||
* Material's line box.
|
||||
*
|
||||
* [trimmedLines] takes the leading off the outer edges of a run of text; this
|
||||
* takes it from between the lines as well, which is the half a wrapped event
|
||||
* title pays for twice over. The line height is *measured* from the font rather
|
||||
* than picked, so it lands exactly on the face's ascent-plus-descent and can
|
||||
* never be short enough to overlap — whatever the font picker has loaded (#190).
|
||||
*/
|
||||
@Composable
|
||||
fun rememberPackedLines(style: TextStyle): TextStyle {
|
||||
val line = rememberTrimmedLineHeight(style)
|
||||
return with(LocalDensity.current) { style.trimmedLines().copy(lineHeight = line.toSp()) }
|
||||
}
|
||||
|
||||
/** What one trimmed line of [style] actually draws in. */
|
||||
@Composable
|
||||
fun rememberTrimmedLineHeight(style: TextStyle): Dp {
|
||||
val measurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
return remember(style, density, measurer) {
|
||||
with(density) { measurer.measure(LINE_SAMPLE, style.trimmedLines()).size.height.toDp() }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.shrinkHorizontally
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -10,12 +18,12 @@ 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.setValue
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -33,7 +41,8 @@ import kotlinx.datetime.LocalDate
|
||||
* 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).
|
||||
* visible day, week start or month anchor); null makes the title a plain label,
|
||||
* as the agenda's is, and the caret folds away.
|
||||
*
|
||||
* [shortTitle] replaces [title] when the full one does not fit the width the app
|
||||
* bar hands the title slot (#165). Either way the line clamps to one and
|
||||
@@ -42,7 +51,7 @@ import kotlinx.datetime.LocalDate
|
||||
@Composable
|
||||
fun CalendarTitleButton(
|
||||
title: String,
|
||||
currentDate: LocalDate,
|
||||
currentDate: LocalDate?,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
shortTitle: String = title,
|
||||
@@ -54,6 +63,7 @@ fun CalendarTitleButton(
|
||||
.heightIn(min = 48.dp)
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
.clickable(
|
||||
enabled = currentDate != null,
|
||||
onClickLabel = stringResource(R.string.drawer_jump_to_date),
|
||||
role = Role.Button,
|
||||
) { showDatePicker = true }
|
||||
@@ -71,21 +81,38 @@ fun CalendarTitleButton(
|
||||
availableWidth = constraints.maxWidth,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = shown,
|
||||
style = style,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
// Cross-fades, so switching views or paging turns the title over
|
||||
// rather than snapping it (#184).
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
AnimatedContent(
|
||||
targetState = shown,
|
||||
transitionSpec = {
|
||||
(fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false))
|
||||
},
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = style,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = currentDate != null,
|
||||
enter = fadeIn(rememberCalendarFadeSpec()) + expandHorizontally(),
|
||||
exit = fadeOut(rememberCalendarFadeSpec()) + shrinkHorizontally(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (showDatePicker) {
|
||||
if (showDatePicker && currentDate != null) {
|
||||
CalendarDatePickerDialog(
|
||||
initial = currentDate,
|
||||
onConfirm = {
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -19,12 +13,12 @@ import androidx.compose.ui.unit.IntOffset
|
||||
* the reduce-motion check — now live in floret-kit's identity module
|
||||
* (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`,
|
||||
* `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays
|
||||
* here is only what's specific to paging the calendar grid: the directional
|
||||
* month/week/day slide and the specs that feed it.
|
||||
* here is only what's specific to paging the calendar grid: the specs its
|
||||
* pagers settle with, and that the view switch reuses.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The M3 Expressive spatial spring used for the month/week/day slide: the
|
||||
* The M3 Expressive spatial spring the calendar's motion is built on: the
|
||||
* *default* spring-physics spec from the active motion scheme, rather than a
|
||||
* fixed easing curve.
|
||||
*
|
||||
@@ -40,6 +34,12 @@ import androidx.compose.ui.unit.IntOffset
|
||||
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
||||
MaterialTheme.motionScheme.defaultSpatialSpec()
|
||||
|
||||
/** The same spring as [rememberCalendarSlideSpec], for a pager settling onto its page. */
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun rememberCalendarPageSpec(): FiniteAnimationSpec<Float> =
|
||||
MaterialTheme.motionScheme.defaultSpatialSpec()
|
||||
|
||||
/**
|
||||
* The effects spec from the active motion scheme, for the opacity half of the
|
||||
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
||||
@@ -55,48 +55,17 @@ fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
|
||||
MaterialTheme.motionScheme.defaultEffectsSpec()
|
||||
|
||||
/**
|
||||
* Navigating between adjacent months/weeks/days, as M3's shared-axis X: the
|
||||
* outgoing page slides and fades one way while the incoming one arrives from the
|
||||
* other, position on a spring and opacity on an easing curve.
|
||||
*
|
||||
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
||||
* (e.g. "today"); a jump reuses the forward direction.
|
||||
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
||||
* @param fadeSpec effects spec for the opacity half, and for the whole
|
||||
* transition under reduced motion; typically
|
||||
* [rememberCalendarFadeSpec].
|
||||
* @param reduceMotion when true, drop the movement and cross-fade alone.
|
||||
* A switch between views (#184): the default spatial spring, a little softer, as
|
||||
* the whole screen reshapes rather than a page sliding by; softer still when
|
||||
* [long], for a change the eye has further to follow.
|
||||
*/
|
||||
fun calendarSlideTransition(
|
||||
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 / SLIDE_TRAVEL_DIVISOR } + fadeIn(fadeSpec),
|
||||
initialContentExit =
|
||||
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
|
||||
// AnimatedContent clips to the animating container by default, which
|
||||
// shears the pages against the viewport edge as they pass. There is no
|
||||
// size change here to contain — both pages are the same grid.
|
||||
sizeTransform = SizeTransform(clip = false),
|
||||
@Composable
|
||||
fun <T> rememberViewSwitchSpec(long: Boolean = false): FiniteAnimationSpec<T> =
|
||||
spring(
|
||||
dampingRatio = VIEW_SWITCH_DAMPING,
|
||||
stiffness = if (long) LONG_VIEW_SWITCH_STIFFNESS else VIEW_SWITCH_STIFFNESS,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* How far a page travels, as a fraction of the container width.
|
||||
*
|
||||
* A full width was the obvious reading of "paging", but the two pages are
|
||||
* stacked and both opaque, so a full-width slide showed one grid racing across
|
||||
* another — the movement carried the whole transition and had a long way to go.
|
||||
* Under M3's shared-axis pattern the offset only has to *hint* the direction
|
||||
* while the cross-fade does the swapping, so a fifth of the width is plenty and
|
||||
* leaves nothing skating past.
|
||||
*/
|
||||
private const val SLIDE_TRAVEL_DIVISOR = 5
|
||||
private const val VIEW_SWITCH_DAMPING = 0.9f
|
||||
private const val VIEW_SWITCH_STIFFNESS = 450f
|
||||
private const val LONG_VIEW_SWITCH_STIFFNESS = 340f
|
||||
|
||||
@@ -1,37 +1,53 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CalendarViewDay
|
||||
import androidx.compose.material.icons.filled.CalendarViewMonth
|
||||
import androidx.compose.material.icons.filled.CalendarViewWeek
|
||||
import androidx.compose.material.icons.filled.ViewAgenda
|
||||
import androidx.compose.material.icons.filled.ViewColumn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
|
||||
/** The top-level calendar views the user can switch between (spec M1). */
|
||||
enum class CalendarView {
|
||||
Month,
|
||||
Week,
|
||||
MultiDay,
|
||||
Day,
|
||||
Agenda,
|
||||
}
|
||||
|
||||
/** Switcher label, shared by the top-bar pill and the drawer's View section. */
|
||||
@get:StringRes
|
||||
val CalendarView.labelRes: Int
|
||||
get() = when (this) {
|
||||
CalendarView.Month -> R.string.view_month
|
||||
CalendarView.Week -> R.string.view_week
|
||||
CalendarView.Day -> R.string.view_day
|
||||
CalendarView.Agenda -> R.string.view_agenda
|
||||
/** How many days the multi-day view shows (#193), provided once at the app root. */
|
||||
val LocalMultiDayCount = staticCompositionLocalOf { SettingsPrefs.DEFAULT_MULTI_DAY_COUNT }
|
||||
|
||||
/**
|
||||
* The view's name, shared by the top-bar pill, the drawer and Settings. The
|
||||
* multi-day view is named after its day count ("3 days").
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarView.label(): String = when (this) {
|
||||
CalendarView.Month -> stringResource(R.string.view_month)
|
||||
CalendarView.Week -> stringResource(R.string.view_week)
|
||||
CalendarView.MultiDay -> {
|
||||
val count = LocalMultiDayCount.current
|
||||
pluralStringResource(R.plurals.view_multi_day, count, count)
|
||||
}
|
||||
CalendarView.Day -> stringResource(R.string.view_day)
|
||||
CalendarView.Agenda -> stringResource(R.string.view_agenda)
|
||||
}
|
||||
|
||||
/** Leading icon for the view in the drawer's View section. */
|
||||
val CalendarView.icon: ImageVector
|
||||
get() = when (this) {
|
||||
CalendarView.Month -> Icons.Filled.CalendarViewMonth
|
||||
CalendarView.Week -> Icons.Filled.CalendarViewWeek
|
||||
CalendarView.MultiDay -> Icons.Filled.ViewColumn
|
||||
CalendarView.Day -> Icons.Filled.CalendarViewDay
|
||||
CalendarView.Agenda -> Icons.Filled.ViewAgenda
|
||||
}
|
||||
@@ -41,7 +57,13 @@ val CalendarView.icon: ImageVector
|
||||
* through these in order.
|
||||
*/
|
||||
val IMPLEMENTED_VIEWS: List<CalendarView> =
|
||||
listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day, CalendarView.Agenda)
|
||||
listOf(
|
||||
CalendarView.Month,
|
||||
CalendarView.Week,
|
||||
CalendarView.MultiDay,
|
||||
CalendarView.Day,
|
||||
CalendarView.Agenda,
|
||||
)
|
||||
|
||||
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
||||
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
||||
@@ -107,3 +129,16 @@ fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarVie
|
||||
/** Push the day view as a drill-in over the current stack (no-op if already on it). */
|
||||
fun List<CalendarView>.drillToDay(): List<CalendarView> =
|
||||
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day
|
||||
|
||||
/** The screens the views are drawn by: the day, multi-day and week views share one timeline (#184). */
|
||||
enum class ViewScreen { Month, Timeline, Agenda }
|
||||
|
||||
val CalendarView.screen: ViewScreen
|
||||
get() = when (this) {
|
||||
CalendarView.Month -> ViewScreen.Month
|
||||
CalendarView.Agenda -> ViewScreen.Agenda
|
||||
CalendarView.Week, CalendarView.MultiDay, CalendarView.Day -> ViewScreen.Timeline
|
||||
}
|
||||
/** Push the week view over the month after a week-number tap. */
|
||||
fun List<CalendarView>.drillToWeek(): List<CalendarView> =
|
||||
if (lastOrNull() == CalendarView.Week) this else this + CalendarView.Week
|
||||
|
||||
@@ -59,7 +59,11 @@ internal fun hourCellRadiusPx(cellHeight: Float, cellWidth: Float, maxRadius: Fl
|
||||
* coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel
|
||||
* height; [color] is resolved by the caller from the theme.
|
||||
*/
|
||||
fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Modifier =
|
||||
fun Modifier.hourGridCells(
|
||||
show: Boolean,
|
||||
hourHeightPx: Float,
|
||||
color: Color,
|
||||
): Modifier =
|
||||
if (!show) {
|
||||
this
|
||||
} else {
|
||||
@@ -71,10 +75,11 @@ fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Mo
|
||||
hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()),
|
||||
)
|
||||
val cellSize = Size(size.width, cellHeight)
|
||||
val top = inset
|
||||
for (hour in 0 until 24) {
|
||||
drawRoundRect(
|
||||
color = color,
|
||||
topLeft = Offset(0f, hour * hourHeightPx + inset),
|
||||
topLeft = Offset(0f, hour * hourHeightPx + top),
|
||||
size = cellSize,
|
||||
cornerRadius = radius,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.onClick
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Thickness of the out-of-view edge bar. */
|
||||
private val EDGE_BAR_THICKNESS = 3.dp
|
||||
|
||||
/** Band the bar sits in, and the room a revealed event keeps from the edge. */
|
||||
private val EDGE_TOUCH_HEIGHT = 24.dp
|
||||
|
||||
/** How far from the viewport's edge a tap on empty column still hits the bar. */
|
||||
private val EDGE_TAP_REACH = 16.dp
|
||||
|
||||
/**
|
||||
* How far the bar sits in from the viewport's edges — enough to clear the
|
||||
* timeline's rounded 16dp corners on the outer columns.
|
||||
*/
|
||||
private val EDGE_BAR_VERTICAL_INSET = 4.dp
|
||||
private val EDGE_BAR_HORIZONTAL_INSET = 6.dp
|
||||
|
||||
/** Distance outside the viewport over which a bar fades before its event appears. */
|
||||
private val EDGE_FADE_LEAD = 24.dp
|
||||
|
||||
/** The nearest timed blocks wholly above and below a column's viewport. */
|
||||
@Immutable
|
||||
data class OutOfViewEvents(val above: TimedBlock?, val below: TimedBlock?) {
|
||||
companion object {
|
||||
val None = OutOfViewEvents(null, null)
|
||||
}
|
||||
}
|
||||
|
||||
/** Which edge of the viewport a hidden event sits past. */
|
||||
enum class ViewportEdge { Top, Bottom }
|
||||
|
||||
/** Where [block]'s rendered bottom lands, in minutes — short blocks keep a floor. */
|
||||
internal fun renderedEndMin(block: TimedBlock): Float =
|
||||
maxOf(block.endMin.toFloat(), block.startMin + MIN_EVENT_FRACTION * 60f)
|
||||
|
||||
/**
|
||||
* The blocks of one column lying out of a viewport spanning [topMin]..[bottomMin]
|
||||
* (minutes of the day), nearest first. A block leaves this set as soon as any
|
||||
* part of its rendered height enters the viewport.
|
||||
*/
|
||||
fun outOfViewEvents(
|
||||
blocks: List<TimedBlock>,
|
||||
topMin: Float,
|
||||
bottomMin: Float,
|
||||
): OutOfViewEvents {
|
||||
val above = blocks
|
||||
.filter { renderedEndMin(it) <= topMin }
|
||||
.maxWithOrNull(compareBy<TimedBlock>({ renderedEndMin(it) }, { it.startMin }))
|
||||
val below = blocks
|
||||
.filter { it.startMin >= bottomMin }
|
||||
.minWithOrNull(compareBy<TimedBlock>({ it.startMin }, { -renderedEndMin(it) }))
|
||||
return OutOfViewEvents(above, below)
|
||||
}
|
||||
|
||||
/**
|
||||
* The scroll offset that brings [block] into a [viewportPx]-tall viewport from
|
||||
* past [edge], with [marginPx] of room so it clears the edge bar. An event above
|
||||
* lands with its start at the top; one below lands whole at the bottom when it
|
||||
* fits, and with its start at the top when it does not.
|
||||
*/
|
||||
fun revealScrollTarget(
|
||||
block: TimedBlock,
|
||||
edge: ViewportEdge,
|
||||
hourPx: Float,
|
||||
viewportPx: Int,
|
||||
marginPx: Float,
|
||||
maxScroll: Int,
|
||||
): Int {
|
||||
val startPx = block.startMin / 60f * hourPx
|
||||
val endPx = renderedEndMin(block) / 60f * hourPx
|
||||
val target = when (edge) {
|
||||
ViewportEdge.Top -> startPx - marginPx
|
||||
ViewportEdge.Bottom -> minOf(endPx + marginPx - viewportPx, startPx - marginPx)
|
||||
}
|
||||
return target.roundToInt().coerceIn(0, maxOf(maxScroll, 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* The edge whose bar a tap at [y] (content px) lands on, if that bar is showing
|
||||
* and the tap is within [reachPx] of the viewport's edge.
|
||||
*/
|
||||
fun tappedEdge(
|
||||
events: OutOfViewEvents,
|
||||
y: Float,
|
||||
viewportTop: Float,
|
||||
viewportPx: Int,
|
||||
reachPx: Float,
|
||||
): ViewportEdge? = when {
|
||||
events.above != null && y - viewportTop <= reachPx -> ViewportEdge.Top
|
||||
events.below != null && viewportTop + viewportPx - y <= reachPx -> ViewportEdge.Bottom
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Fade while the event is still outside; never draw its bar at the boundary. */
|
||||
internal fun edgeBarAlpha(gapPx: Float, fadeLeadPx: Float): Float = when {
|
||||
gapPx <= 0f -> 0f
|
||||
fadeLeadPx <= 0f -> 1f
|
||||
else -> (gapPx / fadeLeadPx).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
/**
|
||||
* The out-of-view state of one day column (#188): which events hide past its
|
||||
* edges, and the scroll that reveals them.
|
||||
*
|
||||
* The bars take no touches themselves, so event blocks under them keep their
|
||||
* taps and drags; the column's own tap handler offers taps to [tap] first.
|
||||
*/
|
||||
@Stable
|
||||
class OutOfViewEdges internal constructor(
|
||||
private val scrollState: ScrollState,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
internal var blocks by mutableStateOf(emptyList<TimedBlock>())
|
||||
internal var hourPx by mutableFloatStateOf(0f)
|
||||
internal var fadeLeadPx by mutableFloatStateOf(0f)
|
||||
internal var marginPx by mutableFloatStateOf(0f)
|
||||
internal var reachPx by mutableFloatStateOf(0f)
|
||||
internal var reduceMotion = false
|
||||
internal var revealSpec: FiniteAnimationSpec<Float>? = null
|
||||
|
||||
val events: OutOfViewEvents by derivedStateOf {
|
||||
val viewport = scrollState.viewportSize
|
||||
if (viewport <= 0 || hourPx <= 0f) {
|
||||
OutOfViewEvents.None
|
||||
} else {
|
||||
val top = scrollState.value.toFloat()
|
||||
outOfViewEvents(
|
||||
blocks = blocks,
|
||||
topMin = top / hourPx * 60f,
|
||||
bottomMin = (top + viewport) / hourPx * 60f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Scrolls the event hidden past [edge] into view, if there is one. */
|
||||
fun reveal(edge: ViewportEdge) {
|
||||
val block = if (edge == ViewportEdge.Top) events.above else events.below
|
||||
block ?: return
|
||||
val target = revealScrollTarget(
|
||||
block = block,
|
||||
edge = edge,
|
||||
hourPx = hourPx,
|
||||
viewportPx = scrollState.viewportSize,
|
||||
marginPx = marginPx,
|
||||
maxScroll = scrollState.maxValue,
|
||||
)
|
||||
val spec = revealSpec
|
||||
scope.launch {
|
||||
if (reduceMotion || spec == null) {
|
||||
scrollState.scrollTo(target)
|
||||
} else {
|
||||
scrollState.animateScrollTo(target, spec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reveals the hidden event if a tap at [y] (content px) lands on a bar. */
|
||||
fun tap(y: Float): Boolean {
|
||||
val edge = tappedEdge(
|
||||
events = events,
|
||||
y = y,
|
||||
viewportTop = scrollState.value.toFloat(),
|
||||
viewportPx = scrollState.viewportSize,
|
||||
reachPx = reachPx,
|
||||
) ?: return false
|
||||
reveal(edge)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberOutOfViewEdges(
|
||||
blocks: List<TimedBlock>,
|
||||
scrollState: ScrollState,
|
||||
hourPx: Float,
|
||||
): OutOfViewEdges {
|
||||
val scope = rememberCoroutineScope()
|
||||
val edges = remember(scrollState, scope) { OutOfViewEdges(scrollState, scope) }
|
||||
val density = LocalDensity.current
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val revealSpec = rememberCalendarPageSpec()
|
||||
SideEffect {
|
||||
edges.blocks = blocks
|
||||
edges.hourPx = hourPx
|
||||
edges.fadeLeadPx = with(density) { EDGE_FADE_LEAD.toPx() }
|
||||
edges.marginPx = with(density) { EDGE_TOUCH_HEIGHT.roundToPx().toFloat() }
|
||||
edges.reachPx = with(density) { EDGE_TAP_REACH.toPx() }
|
||||
edges.reduceMotion = reduceMotion
|
||||
edges.revealSpec = revealSpec
|
||||
}
|
||||
return edges
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin bars at the top and bottom of a day column's visible viewport, tinted in
|
||||
* the colour of the nearest timed event hidden past that edge (#188).
|
||||
*
|
||||
* Lives inside the column's scrolling content and follows the viewport by
|
||||
* offsetting against [scrollState] at placement rather than recomposing every
|
||||
* frame. Opacity follows the gap to the hidden event and reaches zero before
|
||||
* the event enters view. Taps reach it through [OutOfViewEdges.tap]; screen
|
||||
* readers get a button.
|
||||
*/
|
||||
@Composable
|
||||
fun BoxScope.OutOfViewEdgeBars(
|
||||
edges: OutOfViewEdges,
|
||||
scrollState: ScrollState,
|
||||
dark: Boolean,
|
||||
) {
|
||||
val touchPx = with(LocalDensity.current) { EDGE_TOUCH_HEIGHT.roundToPx() }
|
||||
val events = edges.events
|
||||
EdgeBar(
|
||||
block = events.above,
|
||||
edge = ViewportEdge.Top,
|
||||
dark = dark,
|
||||
onReveal = edges::reveal,
|
||||
scrollState = scrollState,
|
||||
hourPx = edges.hourPx,
|
||||
fadeLeadPx = edges.fadeLeadPx,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.offset { IntOffset(0, scrollState.value) },
|
||||
)
|
||||
EdgeBar(
|
||||
block = events.below,
|
||||
edge = ViewportEdge.Bottom,
|
||||
dark = dark,
|
||||
onReveal = edges::reveal,
|
||||
scrollState = scrollState,
|
||||
hourPx = edges.hourPx,
|
||||
fadeLeadPx = edges.fadeLeadPx,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.offset { IntOffset(0, scrollState.value + scrollState.viewportSize - touchPx) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EdgeBar(
|
||||
block: TimedBlock?,
|
||||
edge: ViewportEdge,
|
||||
dark: Boolean,
|
||||
onReveal: (ViewportEdge) -> Unit,
|
||||
scrollState: ScrollState,
|
||||
hourPx: Float,
|
||||
fadeLeadPx: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
block ?: return
|
||||
val color = eventFill(block.event.color, dark, LocalSoftenColors.current)
|
||||
val label = stringResource(
|
||||
if (edge == ViewportEdge.Top) R.string.timeline_earlier_events else R.string.timeline_later_events,
|
||||
)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(EDGE_TOUCH_HEIGHT)
|
||||
.graphicsLayer {
|
||||
val gapPx = when (edge) {
|
||||
ViewportEdge.Top -> scrollState.value - renderedEndMin(block) / 60f * hourPx
|
||||
ViewportEdge.Bottom -> block.startMin / 60f * hourPx -
|
||||
(scrollState.value + scrollState.viewportSize)
|
||||
}
|
||||
alpha = edgeBarAlpha(gapPx, fadeLeadPx)
|
||||
}
|
||||
.semantics {
|
||||
role = Role.Button
|
||||
contentDescription = label
|
||||
onClick {
|
||||
onReveal(edge)
|
||||
true
|
||||
}
|
||||
},
|
||||
contentAlignment = if (edge == ViewportEdge.Top) Alignment.TopCenter else Alignment.BottomCenter,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = EDGE_BAR_HORIZONTAL_INSET,
|
||||
vertical = EDGE_BAR_VERTICAL_INSET,
|
||||
)
|
||||
.height(EDGE_BAR_THICKNESS)
|
||||
.background(color, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
|
||||
/**
|
||||
* One shared state per pager page — a week, a day, a month — so a page and the
|
||||
* screen's anchor read the same query, and the pages either side stay loaded
|
||||
* across a swipe.
|
||||
*
|
||||
* Every entry shares on its own job, cancelled when [keep] lets it go: a
|
||||
* `stateIn` on the ViewModel's scope would stay running, holding its last state,
|
||||
* until the ViewModel itself was cleared. Main thread only.
|
||||
*
|
||||
* @param keep whether a cached key is still worth holding once [get] asks for another.
|
||||
*/
|
||||
class PageStateCache<K, S>(
|
||||
private val scope: CoroutineScope,
|
||||
private val initial: S,
|
||||
private val keep: (cached: K, requested: K) -> Boolean,
|
||||
private val load: (K) -> Flow<S>,
|
||||
) {
|
||||
private class Entry<S>(val state: StateFlow<S>, val job: Job)
|
||||
|
||||
private val entries = HashMap<K, Entry<S>>()
|
||||
|
||||
/** The number of entries held, for tests. */
|
||||
internal val size: Int get() = entries.size
|
||||
|
||||
fun get(key: K): StateFlow<S> {
|
||||
val iterator = entries.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val (cached, entry) = iterator.next()
|
||||
if (cached != key && !keep(cached, key)) {
|
||||
entry.job.cancel()
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
return entries.getOrPut(key) {
|
||||
val job = SupervisorJob(scope.coroutineContext[Job])
|
||||
Entry(
|
||||
state = load(key).stateIn(scope + job, SharingStarted.WhileSubscribed(5_000L), initial),
|
||||
job = job,
|
||||
)
|
||||
}.state
|
||||
}
|
||||
}
|
||||
@@ -284,8 +284,8 @@ class TimelineGeometry {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Hoisted drag state for one timeline (#68). It lives above the pager, 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
|
||||
@@ -790,20 +790,15 @@ private fun DragCopy(
|
||||
val width = with(density) { sizePx.width.toDp() }
|
||||
val height = with(density) { sizePx.height.toDp() }
|
||||
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
||||
val titleLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||
}
|
||||
val timeLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||
}
|
||||
// The block's size, spent the block's way — text sits at the top as it does
|
||||
// on the block, so the copy hands back to the grid without shifting (#267).
|
||||
// on the block, so the copy hands back to the grid without shifting (#267),
|
||||
// and it squeezes its inset on the same terms so a short block's title does
|
||||
// not vanish the moment it is lifted (#289).
|
||||
// The title is served in full first and the range lives off what is left:
|
||||
// the hour gutter down the side still says where the copy sits, so the
|
||||
// range is the half that can afford to go.
|
||||
val available = height - BLOCK_TEXT_INSET * 2
|
||||
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(0)
|
||||
val allowed = titleLines.coerceAtMost(titleBudget)
|
||||
val metrics = rememberBlockTextMetrics(height)
|
||||
val allowed = titleLines.coerceAtMost(metrics.titleBudget(metrics.available))
|
||||
// Re-measured at the copy's own width rather than spent on the source's
|
||||
// count: a block sharing its column with another is a lane wide where the
|
||||
// copy is a whole column, so the source's second line is one the copy never
|
||||
@@ -818,10 +813,10 @@ private fun DragCopy(
|
||||
max = allowed,
|
||||
)
|
||||
}
|
||||
val left = available - titleLineHeight * lines
|
||||
val showTime = label != null && left >= timeLineHeight
|
||||
val left = metrics.available - metrics.titleHeight(lines)
|
||||
val showTime = label != null && left >= metrics.timeLine
|
||||
val timeMaxLines = if (showTime) {
|
||||
blockTimeLines(label!!, textWidth, left - timeLineHeight)
|
||||
blockTimeLines(label!!, textWidth, left - metrics.timeLine)
|
||||
} else {
|
||||
1
|
||||
}
|
||||
@@ -857,7 +852,7 @@ private fun DragCopy(
|
||||
clip = false
|
||||
}
|
||||
.eventSurface(paint, shape, cuts)
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET),
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset),
|
||||
) {
|
||||
Column {
|
||||
if (lines > 0) {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The hours the week and day timelines fit to the screen when they open (#322).
|
||||
*
|
||||
* Nothing outside the range is hidden: [TimelineScale.FitHours] only sizes the
|
||||
* hour so that [startHour]..[endHour] fills the viewport, and the timeline opens
|
||||
* scrolled to [startHour]. The hours are kept when [enabled] is switched off, so
|
||||
* switching it back on restores the chosen range.
|
||||
*/
|
||||
data class TimelineHours(
|
||||
val enabled: Boolean = false,
|
||||
val startHour: Int = DEFAULT_START_HOUR,
|
||||
val endHour: Int = DEFAULT_END_HOUR,
|
||||
) {
|
||||
val span: Int get() = endHour - startHour
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_START_HOUR = 7
|
||||
const val DEFAULT_END_HOUR = 22
|
||||
|
||||
/** Shortest range that can be chosen, in hours. */
|
||||
const val MIN_SPAN = 2
|
||||
|
||||
/**
|
||||
* A range with [startHour] and [endHour] held to the day and at least
|
||||
* [MIN_SPAN] apart; the start gives way when the two collide.
|
||||
*/
|
||||
fun of(enabled: Boolean, startHour: Int, endHour: Int): TimelineHours {
|
||||
val end = endHour.coerceIn(MIN_SPAN, 24)
|
||||
val start = startHour.coerceIn(0, end - MIN_SPAN)
|
||||
return TimelineHours(enabled, start, end)
|
||||
}
|
||||
|
||||
/** Start hours that still leave [MIN_SPAN] before [endHour]. */
|
||||
fun startOptions(endHour: Int): List<Int> = (0..endHour - MIN_SPAN).toList()
|
||||
|
||||
/** End hours at least [MIN_SPAN] after [startHour]; 24 is midnight. */
|
||||
fun endOptions(startHour: Int): List<Int> = (startHour + MIN_SPAN..24).toList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The scroll offset that puts [startHour] at the top of a timeline laid out
|
||||
* with [maxValue] scroll range over a [viewportPx] viewport, or null while that
|
||||
* layout is not yet at [hourPx] per hour — the scale changes a frame before the
|
||||
* content grows to match it, and scrolling against the old layout would land on
|
||||
* the wrong hour.
|
||||
*/
|
||||
internal fun startHourScroll(maxValue: Int, viewportPx: Int, startHour: Int, hourPx: Float): Int? {
|
||||
if (viewportPx <= 0 || maxValue < 0 || maxValue == Int.MAX_VALUE) return null
|
||||
val laidOutHourPx = (maxValue + viewportPx) / 24f
|
||||
if (abs(laidOutHourPx - hourPx) > 1f) return null
|
||||
return (startHour * laidOutHourPx).roundToInt().coerceIn(0, maxValue)
|
||||
}
|
||||
@@ -38,6 +38,12 @@ sealed interface TimelineScale {
|
||||
/** A height the user pinched to. Build it through [custom], which clamps. */
|
||||
data class Custom(val hourHeight: Dp) : TimelineScale
|
||||
|
||||
/**
|
||||
* [startHour]..[endHour] fills the viewport (#322). Applied when a timeline
|
||||
* opens with [TimelineHours] on, never picked from the preset list.
|
||||
*/
|
||||
data class FitHours(val startHour: Int, val endHour: Int) : TimelineScale
|
||||
|
||||
companion object {
|
||||
/** The named steps the settings picker offers, coarse to roomy. */
|
||||
val presets: List<TimelineScale> = listOf(FitDay, Compact, Regular, Comfortable)
|
||||
@@ -74,6 +80,9 @@ fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) {
|
||||
is TimelineScale.Custom -> this.hourHeight
|
||||
.coerceAtMost(MAX_PINCH_HOUR_HEIGHT)
|
||||
.coerceAtLeast(fillHourHeight(viewportHeight))
|
||||
is TimelineScale.FitHours -> (viewportHeight / (endHour - startHour).coerceIn(1, 24).toFloat())
|
||||
.coerceAtMost(MAX_PINCH_HOUR_HEIGHT)
|
||||
.coerceAtLeast(fillHourHeight(viewportHeight))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,6 +143,7 @@ val TimelineScale.labelRes: Int
|
||||
TimelineScale.Regular -> R.string.timeline_scale_regular
|
||||
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable
|
||||
is TimelineScale.Custom -> R.string.timeline_scale_custom
|
||||
is TimelineScale.FitHours -> R.string.settings_visible_hours
|
||||
}
|
||||
|
||||
@get:StringRes
|
||||
@@ -144,11 +154,15 @@ val TimelineScale.descriptionRes: Int
|
||||
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
|
||||
is TimelineScale.FitHours -> R.string.settings_visible_hours_summary
|
||||
}
|
||||
|
||||
/** Marks a stored custom height; the rest of the value is its dp. */
|
||||
private const val CUSTOM_PREFIX = "custom:"
|
||||
|
||||
/** Marks a stored hour range, as `start-end`. */
|
||||
private const val HOURS_PREFIX = "hours:"
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -171,6 +185,7 @@ private val PRESET_NAMES: Map<TimelineScale, String> = mapOf(
|
||||
*/
|
||||
fun TimelineScale.storageValue(): String = when (this) {
|
||||
is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value
|
||||
is TimelineScale.FitHours -> "$HOURS_PREFIX$startHour-$endHour"
|
||||
TimelineScale.FitDay,
|
||||
TimelineScale.Compact,
|
||||
TimelineScale.Regular,
|
||||
@@ -188,5 +203,11 @@ fun parseTimelineScale(stored: String?): TimelineScale = when {
|
||||
stored.startsWith(CUSTOM_PREFIX) -> stored.removePrefix(CUSTOM_PREFIX).toFloatOrNull()
|
||||
?.let { TimelineScale.custom(it.dp) }
|
||||
?: TimelineScale.Regular
|
||||
stored.startsWith(HOURS_PREFIX) -> stored.removePrefix(HOURS_PREFIX).split('-')
|
||||
.mapNotNull { it.toIntOrNull() }
|
||||
.takeIf { it.size == 2 }
|
||||
?.let { (start, end) -> TimelineHours.of(enabled = true, start, end) }
|
||||
?.let { TimelineScale.FitHours(it.startHour, it.endHour) }
|
||||
?: TimelineScale.Regular
|
||||
else -> PRESET_NAMES.entries.firstOrNull { it.value == stored }?.key ?: TimelineScale.Regular
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Where 00:00 sat in the last timeline shown, measured from the top of its
|
||||
* view's content, so the next timeline view opens on the same hours (#184): an
|
||||
* event in both keeps its place and only grows sideways.
|
||||
*/
|
||||
class TimelineScrollMemory {
|
||||
var midnightY: Float? = null
|
||||
}
|
||||
|
||||
val LocalTimelineScroll = staticCompositionLocalOf<TimelineScrollMemory?> { null }
|
||||
|
||||
/** A timeline's scroll, and where it opens before it can scroll there. */
|
||||
@Stable
|
||||
class TimelineScroll(val state: ScrollState, noonPending: Boolean, private val openingHour: Int?) {
|
||||
/** Whether it is still to be put on noon, which it only can be once laid out. */
|
||||
var noonPending by mutableStateOf(noonPending)
|
||||
internal set
|
||||
|
||||
/** The top of what it shows, or will once open, over [content] pixels in a [viewport] pixels high. */
|
||||
fun top(viewport: Float, content: Float): Float =
|
||||
if (noonPending) {
|
||||
(openingHour?.let { it * content / 24f } ?: (content - viewport) / 2f)
|
||||
.coerceIn(0f, (content - viewport).coerceAtLeast(0f))
|
||||
} else state.value.toFloat()
|
||||
}
|
||||
|
||||
val LocalOpeningTimeline = staticCompositionLocalOf<TimelineScroll?> { null }
|
||||
|
||||
/**
|
||||
* The scroll state of a timeline whose scrolling area starts [timelineTop] below
|
||||
* its view's content top. Opens where the last timeline left off, or on noon the
|
||||
* first time.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
|
||||
val memory = LocalTimelineScroll.current
|
||||
val density = LocalDensity.current
|
||||
val zoom = LocalTimelineZoom.current
|
||||
val topPx = with(density) { timelineTop.toPx() }
|
||||
val remembered = memory?.midnightY
|
||||
val scrollState = rememberScrollState(
|
||||
initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0,
|
||||
)
|
||||
val scroll = remember(scrollState) {
|
||||
TimelineScroll(
|
||||
scrollState,
|
||||
noonPending = remembered == null,
|
||||
openingHour = zoom.hours.takeIf { it.enabled }?.startHour,
|
||||
)
|
||||
}
|
||||
val currentTop by rememberUpdatedState(topPx)
|
||||
var firstOpen by remember(scrollState) { mutableStateOf(true) }
|
||||
LaunchedEffect(scrollState, zoom, zoom.hours) {
|
||||
if (firstOpen && remembered != null) {
|
||||
firstOpen = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
firstOpen = false
|
||||
val startHour = zoom.open()
|
||||
if (startHour == null) {
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||
} else {
|
||||
val (target, hourHeight) = snapshotFlow {
|
||||
val viewport = scrollState.viewportSize
|
||||
val hourHeight = zoom.scale.hourHeight(with(density) { viewport.toDp() })
|
||||
val hourPx = with(density) { hourHeight.toPx() }
|
||||
startHourScroll(scrollState.maxValue, viewport, startHour, hourPx)?.let { it to hourHeight }
|
||||
}.filterNotNull().first()
|
||||
scrollState.scrollTo(target)
|
||||
zoom.holdFit(hourHeight)
|
||||
}
|
||||
scroll.noonPending = false
|
||||
}
|
||||
if (memory != null) {
|
||||
LaunchedEffect(scrollState, memory) {
|
||||
snapshotFlow { currentTop - scrollState.value }.collect { memory.midnightY = it }
|
||||
}
|
||||
}
|
||||
return scroll
|
||||
}
|
||||
@@ -19,7 +19,6 @@ 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
|
||||
|
||||
@@ -50,13 +49,49 @@ class TimelineZoom(
|
||||
var isPinching: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/** The visible-hours preference (#322); a timeline re-fits when it changes. */
|
||||
var hours: TimelineHours by mutableStateOf(TimelineHours())
|
||||
private set
|
||||
|
||||
private var stored: TimelineScale = initial
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* snap the timeline back while the user is still pinching. With visible
|
||||
* hours on it is only remembered, so a late-arriving preference can't undo
|
||||
* the fit a timeline just opened with.
|
||||
*/
|
||||
fun adopt(stored: TimelineScale) {
|
||||
if (!isPinching) scale = stored
|
||||
this.stored = stored
|
||||
if (!isPinching && !hours.enabled) scale = stored
|
||||
}
|
||||
|
||||
/** Take the visible-hours preference; switching it off hands back the stored scale. */
|
||||
fun adoptHours(hours: TimelineHours) {
|
||||
val wasEnabled = this.hours.enabled
|
||||
this.hours = hours
|
||||
if (wasEnabled && !hours.enabled && !isPinching) scale = stored
|
||||
}
|
||||
|
||||
/**
|
||||
* A timeline is opening. With visible hours on, the range replaces whatever
|
||||
* scale is in force — a stored preset and a pinch alike — and its start hour
|
||||
* is returned for the timeline to scroll to; otherwise null.
|
||||
*/
|
||||
fun open(): Int? {
|
||||
val hours = hours
|
||||
if (!hours.enabled) return null
|
||||
if (!isPinching) scale = TimelineScale.FitHours(hours.startHour, hours.endHour)
|
||||
return hours.startHour
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the height [open] fitted, so a viewport that changes afterwards (the
|
||||
* all-day strip following a swipe) doesn't rescale the hours under the scroll.
|
||||
*/
|
||||
fun holdFit(hourHeight: Dp) {
|
||||
if (!isPinching && scale is TimelineScale.FitHours) scale = TimelineScale.Custom(hourHeight)
|
||||
}
|
||||
|
||||
fun beginPinch() {
|
||||
@@ -67,9 +102,10 @@ class TimelineZoom(
|
||||
scale = TimelineScale.custom(hourHeight)
|
||||
}
|
||||
|
||||
/** Settles the pinch. With visible hours on it only lasts until the next [open]. */
|
||||
fun endPinch() {
|
||||
isPinching = false
|
||||
persist(scale)
|
||||
if (!hours.enabled) persist(scale)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,16 +119,18 @@ 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].
|
||||
* The app-wide [TimelineZoom], seeded from [stored] and [hours] and writing
|
||||
* settled pinches back through [onPersist].
|
||||
*/
|
||||
@Composable
|
||||
fun rememberTimelineZoom(
|
||||
stored: TimelineScale,
|
||||
hours: TimelineHours,
|
||||
onPersist: (TimelineScale) -> Unit,
|
||||
): TimelineZoom {
|
||||
val persist by rememberUpdatedState(onPersist)
|
||||
val zoom = remember { TimelineZoom(stored) { persist(it) } }
|
||||
LaunchedEffect(hours) { zoom.adoptHours(hours) }
|
||||
// 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) }
|
||||
@@ -197,18 +235,26 @@ fun rememberTimelinePinchZoom(
|
||||
* 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.
|
||||
* The ceiling is pulled onto that grid too, downwards, so it stays a height the
|
||||
* pinch can actually land on.
|
||||
*
|
||||
* [fillPx] is deliberately *not* rounded (#290). It is the one height the whole
|
||||
* day exactly fills the viewport at, and it is the same value
|
||||
* [TimelineScale.FitDay] resolves to — rounding it up by the fraction of a pixel
|
||||
* that 24 hours don't divide the viewport into leaves the timeline a pixel per
|
||||
* hour taller than its own viewport, so a pinched-all-the-way-out day still
|
||||
* scrolls a hair and bounces off Android's overscroll stretch, while the
|
||||
* identical FitDay preset sits still. Being the clamp result rather than a bound
|
||||
* the gesture is merely held against, it is a height the pinch does land on: the
|
||||
* next frame reads it back unchanged and the focal anchor is handed nothing to
|
||||
* correct.
|
||||
*/
|
||||
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))
|
||||
.coerceAtLeast(fillPx)
|
||||
|
||||
/**
|
||||
* The scroll offset that keeps the moment under [centroidY] under it after the
|
||||
|
||||
@@ -7,9 +7,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
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
|
||||
@@ -59,7 +59,8 @@ fun TodayAction(show: Boolean, onToday: () -> Unit) {
|
||||
val description = stringResource(R.string.today_jump_action)
|
||||
IconButton(
|
||||
onClick = onToday,
|
||||
modifier = Modifier.semantics { contentDescription = description },
|
||||
modifier = Modifier
|
||||
.semantics { contentDescription = description },
|
||||
) {
|
||||
val textSize = with(LocalDensity.current) { GlyphTextHeight.toSp() }
|
||||
Box(
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* The date every calendar view is looking at (#184), so switching views keeps
|
||||
* the same days on screen and what is shown in both can travel between them.
|
||||
*
|
||||
* A view reads it once, as it comes on screen, and writes it back only when the
|
||||
* user moves: a view settling where it opened must not nudge it, or switching
|
||||
* back and forth would drift.
|
||||
*/
|
||||
@Stable
|
||||
class ViewFocus(initial: LocalDate) {
|
||||
var date by mutableStateOf(initial)
|
||||
/** Explicit navigation, including a request for the view already on screen. */
|
||||
var navigationVersion by mutableStateOf(0)
|
||||
private set
|
||||
|
||||
fun navigateTo(target: LocalDate) {
|
||||
date = target
|
||||
navigationVersion++
|
||||
}
|
||||
}
|
||||
|
||||
val ViewFocusSaver: Saver<ViewFocus, String> = Saver(
|
||||
save = { it.date.toString() },
|
||||
restore = { ViewFocus(LocalDate.parse(it)) },
|
||||
)
|
||||
|
||||
val LocalViewFocus = staticCompositionLocalOf<ViewFocus?> { null }
|
||||
|
||||
/**
|
||||
* Runs [enter] with the focused date once, as the view comes on screen, before
|
||||
* it reads its own position: its first frame is then already on that date.
|
||||
*/
|
||||
@Composable
|
||||
fun <T : Any> EnterOnFocus(key: T, enter: (LocalDate) -> Unit) {
|
||||
val focus = LocalViewFocus.current
|
||||
remember(key) { focus?.date?.also(enter) }
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.BoundsTransform
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.mutableStateSetOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* What the views share when switching between them (#184): an event visible in
|
||||
* both grows from where it sat into where it lands, a date label travels to its
|
||||
* new seat, and everything else fades through.
|
||||
*
|
||||
* An element is tagged with the day it is drawn *from*, not just its instance: a
|
||||
* multi-day event is drawn once per day, per week row or per timeline column,
|
||||
* and only one element per key may take part. The segment that starts on the
|
||||
* same day in both views is the one that travels; the rest fade where they are.
|
||||
*/
|
||||
sealed interface ViewMorphKey {
|
||||
data class Event(val eventId: Long, val beginMillis: Long, val day: LocalDate) : ViewMorphKey
|
||||
|
||||
data class Day(val date: LocalDate) : ViewMorphKey
|
||||
|
||||
|
||||
companion object {
|
||||
/** [event]'s occurrence as drawn from [day]. */
|
||||
fun event(event: EventInstance, day: LocalDate): Event =
|
||||
Event(event.eventId, event.start.toEpochMilliseconds(), day)
|
||||
}
|
||||
}
|
||||
|
||||
/** The view switch's scopes and specs, captured where they exist. Null under reduced motion. */
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
class ViewMorphScope(
|
||||
val shared: SharedTransitionScope,
|
||||
val visibility: AnimatedVisibilityScope,
|
||||
val boundsTransform: BoundsTransform,
|
||||
val fadeSpec: FiniteAnimationSpec<Float>,
|
||||
val clip: BelowTopBarClip,
|
||||
val stayingPut: StayingPut,
|
||||
)
|
||||
|
||||
/**
|
||||
* The events a view has but keeps out of the switch, being scrolled out of
|
||||
* sight: the other view leaves them untagged too, or its half would travel to
|
||||
* nowhere and snap into place at the end.
|
||||
*/
|
||||
class StayingPut {
|
||||
val keys = mutableStateSetOf<ViewMorphKey.Event>()
|
||||
}
|
||||
|
||||
/** Marks [key] as staying put while [stays], for as long as it is composed. */
|
||||
@Composable
|
||||
fun StayPut(key: ViewMorphKey.Event, stays: Boolean) {
|
||||
val stayingPut = LocalViewMorph.current?.stayingPut ?: return
|
||||
if (!stays) return
|
||||
DisposableEffect(stayingPut, key) {
|
||||
stayingPut.keys += key
|
||||
onDispose { stayingPut.keys -= key }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps what travels below the top bar, which every view has at the same height:
|
||||
* the overlay draws above the whole screen, so an event scrolled under the bar
|
||||
* would otherwise cross it. [top] is the bar's bottom edge in the shared layout.
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
class BelowTopBarClip : SharedTransitionScope.OverlayClip {
|
||||
var top = 0f
|
||||
|
||||
override fun getClipPath(
|
||||
sharedContentState: SharedTransitionScope.SharedContentState,
|
||||
bounds: Rect,
|
||||
layoutDirection: LayoutDirection,
|
||||
density: Density,
|
||||
): Path = Path().apply { addRect(Rect(-FAR, top, FAR, FAR)) }
|
||||
|
||||
private companion object {
|
||||
const val FAR = 1e6f
|
||||
}
|
||||
}
|
||||
|
||||
val LocalViewMorph = compositionLocalOf<ViewMorphScope?> { null }
|
||||
|
||||
/**
|
||||
* Whether a pager page may carry the morph tags: only the settled one. Its
|
||||
* neighbours are composed ahead and repeat some of its dates, which would both
|
||||
* claim its keys and fly in from off screen.
|
||||
*/
|
||||
fun morphPageActive(page: Int, settledPage: Int): Boolean = page == settledPage
|
||||
|
||||
/** Provides the view morph to [content] only while [active] — see [morphPageActive]. */
|
||||
@Composable
|
||||
fun ViewMorphPage(active: Boolean, content: @Composable () -> Unit) {
|
||||
val morph = LocalViewMorph.current
|
||||
CompositionLocalProvider(LocalViewMorph provides morph.takeIf { active }, content = content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tags an event chip, bar, block or row. Its bounds are shared and the two faces
|
||||
* cross-fade inside them; remeasured rather than scaled, so a title keeps its
|
||||
* real size and is clipped while there is no room, instead of smearing.
|
||||
*
|
||||
* Goes after the element's offset and before its size, so the travelling bounds
|
||||
* drive the measurement.
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun Modifier.morphEvent(key: ViewMorphKey.Event): Modifier {
|
||||
val morph = LocalViewMorph.current ?: return this
|
||||
if (key in morph.stayingPut.keys) return this
|
||||
return with(morph.shared) {
|
||||
this@morphEvent.sharedBounds(
|
||||
sharedContentState = rememberSharedContentState(key),
|
||||
animatedVisibilityScope = morph.visibility,
|
||||
enter = fadeIn(morph.fadeSpec),
|
||||
exit = fadeOut(morph.fadeSpec),
|
||||
boundsTransform = morph.boundsTransform,
|
||||
clipInOverlayDuringTransition = morph.clip,
|
||||
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
||||
zIndexInOverlay = 1f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Tags a day's number, which is small on both sides, so it scales on its way. */
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun Modifier.morphDay(date: LocalDate): Modifier {
|
||||
val morph = LocalViewMorph.current ?: return this
|
||||
return with(morph.shared) {
|
||||
this@morphDay.sharedBounds(
|
||||
sharedContentState = rememberSharedContentState(ViewMorphKey.Day(date)),
|
||||
animatedVisibilityScope = morph.visibility,
|
||||
enter = fadeIn(morph.fadeSpec),
|
||||
exit = fadeOut(morph.fadeSpec),
|
||||
boundsTransform = morph.boundsTransform,
|
||||
clipInOverlayDuringTransition = morph.clip,
|
||||
zIndexInOverlay = 1f,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The view switch's travel, on the same spring as the timeline's columns resizing. */
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun rememberViewMorphBoundsTransform(): BoundsTransform {
|
||||
val spec = rememberViewSwitchSpec<Rect>()
|
||||
return remember(spec) { BoundsTransform { _, _ -> spec } }
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
/**
|
||||
@@ -30,11 +35,23 @@ fun ViewSwitcherPill(
|
||||
trailingInset: Dp = AppBarSpacing.ContainerTrailingInset,
|
||||
) {
|
||||
if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
FilledTonalButton(
|
||||
onClick = onCycle,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
modifier = modifier.padding(end = trailingInset),
|
||||
modifier = modifier
|
||||
.padding(end = trailingInset),
|
||||
) {
|
||||
Text(stringResource(current.labelRes))
|
||||
// The label cross-fades and the pill eases to its width, so cycling
|
||||
// views turns it over rather than snapping it (#184).
|
||||
AnimatedContent(
|
||||
targetState = current.label(),
|
||||
transitionSpec = {
|
||||
(fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false))
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) { label ->
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.plus
|
||||
|
||||
/** Pages the day pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */
|
||||
internal const val DAY_PAGE_COUNT: Int = 140_000
|
||||
|
||||
private const val EPOCH_PAGE = DAY_PAGE_COUNT / 2
|
||||
|
||||
/** The day at [EPOCH_PAGE]; any date works, it only has to stay fixed. */
|
||||
private val PAGE_EPOCH = LocalDate(2000, 1, 1)
|
||||
|
||||
/** The day shown on pager [page]. */
|
||||
internal fun dayForPage(page: Int): LocalDate =
|
||||
PAGE_EPOCH.plus(page - EPOCH_PAGE, DateTimeUnit.DAY)
|
||||
|
||||
/** The pager page showing [date]. */
|
||||
internal fun dayPageFor(date: LocalDate): Int =
|
||||
(EPOCH_PAGE + PAGE_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1)
|
||||
@@ -1,907 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.ScrollState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalChipGround
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventPaint
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventSurface
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
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.TodayAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
|
||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
||||
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
|
||||
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
|
||||
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
|
||||
import de.jeanlucmakiola.calendula.ui.common.ChipCuts
|
||||
import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts
|
||||
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
|
||||
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.GUTTER_WIDTH
|
||||
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
||||
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
|
||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||
|
||||
/** Total all-day strip height for the day (0 when there are no all-day events). */
|
||||
internal fun DayUiState.Success.allDayStripHeight(): Dp {
|
||||
if (allDay.isEmpty()) return 0.dp
|
||||
val lanes = allDay.maxOf { it.lane } + 1
|
||||
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DayScreen(
|
||||
selectedView: CalendarView,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
todayInToolbar: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
initialDateIso: String? = null,
|
||||
viewModel: DayViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val date by viewModel.date.collectAsStateWithLifecycle()
|
||||
|
||||
// When opened from the month grid, anchor to the tapped date.
|
||||
LaunchedEffect(initialDateIso) {
|
||||
initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) }
|
||||
}
|
||||
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val isOnToday = when (val s = state) {
|
||||
is DayUiState.Success -> s.date == s.today
|
||||
else -> true
|
||||
}
|
||||
|
||||
// Drives whether the title carries the year. Falls back to the clock only
|
||||
// while the first load is in flight, when there is no state to read today from.
|
||||
val currentYear = when (val s = state) {
|
||||
is DayUiState.Success -> s.today.year
|
||||
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
||||
}
|
||||
|
||||
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
|
||||
var slideDir by remember { mutableIntStateOf(0) }
|
||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||
val goPrev = { slideDir = -1; viewModel.goToPrev() }
|
||||
// Slide toward today: viewing the future → today comes in from the left
|
||||
// (back), viewing the past → from the right (forward).
|
||||
val jumpToToday = {
|
||||
slideDir = when (val s = state) {
|
||||
is DayUiState.Success -> if (s.today < s.date) -1 else 1
|
||||
else -> 0
|
||||
}
|
||||
viewModel.goToToday()
|
||||
}
|
||||
// Drawer jump-to-date: slide from the side the target lies on.
|
||||
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||
slideDir = if (target < date) -1 else 1
|
||||
viewModel.goToDate(target)
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
// Open only via the menu button — edge-swipe would fight the day swipe.
|
||||
gesturesEnabled = drawerState.isOpen,
|
||||
drawerContent = {
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = date,
|
||||
drawerState = drawerState,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onJumpToDate = { target ->
|
||||
jumpToDate(target)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onSettings = {
|
||||
onOpenSettings()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
DayTopBar(
|
||||
date = date,
|
||||
currentYear = currentYear,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
onJumpToDate = jumpToDate,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = jumpToToday,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
CalendarFabColumn(
|
||||
todayVisible = !isOnToday && !todayInToolbar,
|
||||
todayText = stringResource(R.string.day_today_action),
|
||||
onToday = jumpToToday,
|
||||
onCreate = { onCreateEvent(date, null) },
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
DayContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayContent(
|
||||
state: DayUiState,
|
||||
slideDir: Int,
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
|
||||
// Hoisted above the per-day AnimatedContent so the vertical scroll position
|
||||
// survives day-to-day swipes. We only centre on noon once, on first entry
|
||||
// into the day view (i.e. when arriving from the month/week view).
|
||||
val scrollState = rememberScrollState()
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
// Half the scroll range *is* noon: the content spans a full 24 hours, so
|
||||
// centring the range centres midday at whatever hour height is in force.
|
||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||
}
|
||||
|
||||
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
||||
// day during a swipe, so the strip slides along but never jumps in height.
|
||||
val targetAllDayHeight = (state as? DayUiState.Success)?.allDayStripHeight() ?: 0.dp
|
||||
val allDayHeight by animateDpAsState(
|
||||
targetValue = targetAllDayHeight,
|
||||
label = "day-all-day-strip-height",
|
||||
)
|
||||
|
||||
// Whole-page horizontal swipe, one level above the timeline's vertical
|
||||
// scroll: a horizontal drag crosses this detector's slop, while a vertical
|
||||
// drag is consumed by the inner scroll first — the two gestures coexist.
|
||||
val swipeModifier = 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(
|
||||
targetState = state,
|
||||
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
when (s) {
|
||||
is DayUiState.Success -> "success-${s.date}"
|
||||
is DayUiState.Failure -> "failure-${s.reason}"
|
||||
DayUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "day-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
DayUiState.Loading -> DayLoading()
|
||||
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is DayUiState.Success -> DaySuccess(
|
||||
state = s,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = { drop ->
|
||||
move?.move(
|
||||
MoveRequest(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
TimelineDragOverlay(dragController)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DaySuccess(
|
||||
state: DayUiState.Success,
|
||||
scrollState: ScrollState,
|
||||
allDayHeight: Dp,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// All-day strip collapses to nothing when the day has no all-day events,
|
||||
// so the timeline sits directly under the app bar.
|
||||
// This strip is painted on `surface`, so an outlined chip has to fill
|
||||
// with that and not with the column's container (#230).
|
||||
CompositionLocalProvider(
|
||||
LocalChipGround provides MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
AllDayStrip(
|
||||
state = state,
|
||||
height = allDayHeight,
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
)
|
||||
}
|
||||
// Breathing room between the top section and the scrolling timeline
|
||||
// below.
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Timeline(
|
||||
state = state,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DayTopBar(
|
||||
date: LocalDate,
|
||||
currentYear: Int,
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
quickSwitchViews: List<CalendarView>,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
val (title, shortTitle) = remember(date, locale, currentYear) {
|
||||
formatDayTitle(date, locale, currentYear) to
|
||||
formatDayTitle(date, locale, currentYear, abbreviated = true)
|
||||
}
|
||||
TopAppBar(
|
||||
title = {
|
||||
CalendarTitleButton(
|
||||
title = title,
|
||||
currentDate = date,
|
||||
onJumpToDate = onJumpToDate,
|
||||
shortTitle = shortTitle,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(R.string.month_open_menu),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TodayAction(show = showTodayButton, onToday = onToday)
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = stringResource(R.string.search_action),
|
||||
)
|
||||
}
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
cycle = quickSwitchViews,
|
||||
onCycle = onCycleView,
|
||||
)
|
||||
},
|
||||
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
||||
// it, but here the bar meets the header on the same surface and the
|
||||
// tint is what makes that seam look like a separate block (#186).
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AllDayStrip(
|
||||
state: DayUiState.Success,
|
||||
height: Dp,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
// Height is hoisted + animated so it resizes smoothly; padding sits
|
||||
// inside it so the content area is lanes * row height.
|
||||
.height(height)
|
||||
.padding(
|
||||
top = ALL_DAY_VERTICAL_PADDING,
|
||||
bottom = ALL_DAY_VERTICAL_PADDING,
|
||||
end = TIMELINE_CONTENT_END_INSET,
|
||||
),
|
||||
) {
|
||||
// Keep the gutter-width offset so the bars line up with the day column.
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
// Bars are positioned absolutely by lane (vertical stacking); each spans
|
||||
// the full day-column width. clipToBounds keeps bars from spilling out
|
||||
// while the height animates.
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.clipToBounds(),
|
||||
) {
|
||||
val barWidth = maxWidth
|
||||
state.allDay.forEach { span ->
|
||||
AllDayBar(
|
||||
event = span.event,
|
||||
dark = dark,
|
||||
onClick = { onEventClick(span.event) },
|
||||
modifier = Modifier
|
||||
.offset(y = ALL_DAY_ROW_HEIGHT * span.lane)
|
||||
.width(barWidth)
|
||||
.height(ALL_DAY_ROW_HEIGHT)
|
||||
.padding(horizontal = 1.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AllDayBar(
|
||||
event: EventInstance,
|
||||
dark: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val paint = eventPaint(event, dark)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.eventSurface(paint, EventChipShape)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = title },
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
val titleOverflow = eventTitleOverflow()
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = titleOverflow.overflow,
|
||||
softWrap = titleOverflow.softWrap,
|
||||
color = paint.titleInk,
|
||||
fontWeight = paint.titleWeight,
|
||||
textDecoration = paint.decoration,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Timeline(
|
||||
state: DayUiState.Success,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val zoom = LocalTimelineZoom.current
|
||||
val density = LocalDensity.current
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
// What the hour grid takes off a block's ends, for the floating copy to
|
||||
// take off its own. Per-end, a cut edge keeps it — that much the drag
|
||||
// geometry decides itself, from the slice it is drawing.
|
||||
val blockInsetPx = with(density) {
|
||||
hourCellBlockInset(LocalShowHourGrid.current, cut = false).toPx()
|
||||
}
|
||||
|
||||
// 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
|
||||
// state, so they stay perfectly aligned. The day-column viewport is a
|
||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||
// soft corners are permanent at any scroll position.
|
||||
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
||||
// Hour gutter (scrolls in sync with the day column). Start inset so the
|
||||
// labels centre on the top bar hamburger, matching the week view.
|
||||
HourGutter(
|
||||
scrollState = scrollState,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
)
|
||||
// Day column: rounded, clipped scroll viewport (permanent corners).
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.verticalScroll(scrollState)
|
||||
.onGloballyPositioned { dragController.geometry.viewport = it },
|
||||
) {
|
||||
DayColumnCard(
|
||||
blocks = state.timed,
|
||||
dark = dark,
|
||||
date = state.date,
|
||||
today = state.today,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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.blockInsetPx = blockInsetPx
|
||||
it.columnGapPx = 0f
|
||||
it.columnWidthPx = coords.size.width.toFloat()
|
||||
it.days = listOf(state.date)
|
||||
it.isRtl = isRtl
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayColumnCard(
|
||||
blocks: List<TimedBlock>,
|
||||
dark: Boolean,
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
hourHeight: Dp,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
||||
val showHourGrid = LocalShowHourGrid.current
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
// Tells a settled drop when this column has caught up with it.
|
||||
LaunchedEffect(blocks, dragController.settling) {
|
||||
dragController.noteGrid(date, blocks)
|
||||
}
|
||||
// The tap detector outlives the composition that installed it — a pinch or a
|
||||
// Settings change moves the hour height without restarting it — so it reads
|
||||
// the height and the callback through state handles instead of capturing
|
||||
// them, or taps land on the scale the column had before the zoom (#148).
|
||||
val currentHourPx = rememberUpdatedState(hourPx)
|
||||
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
|
||||
Card(
|
||||
// Plain rectangular column — the soft corners come from the outer
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
shape = RectangleShape,
|
||||
colors = CardDefaults.cardColors(
|
||||
// With the grid on, the container colour moves onto the hour cells
|
||||
// and the column behind them recedes to surface, so an hour boundary
|
||||
// reads as negative space between two surfaces.
|
||||
containerColor = if (showHourGrid) {
|
||||
MaterialTheme.colorScheme.surface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainer
|
||||
},
|
||||
),
|
||||
modifier = modifier,
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// The hour cells sit over the column background but under the
|
||||
// event blocks (drawBehind paints before the children).
|
||||
.hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer)
|
||||
// Tap an empty slot to create an event there. Taps on event
|
||||
// blocks are consumed by their own click handler first, so this
|
||||
// only fires on the column background. Snaps to the tapped hour.
|
||||
.pointerInput(date) {
|
||||
detectTapGestures { offset ->
|
||||
currentOnCreateAt.value(
|
||||
date,
|
||||
tappedMinuteOfDay(offset.y, currentHourPx.value),
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
val colWidth = maxWidth
|
||||
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
||||
// Keyed by event, so a block that changes time or lane is the *same*
|
||||
// composable afterwards and tweens there. The ordinal disambiguates
|
||||
// a column holding two occurrences of one series.
|
||||
val ordinals = mutableMapOf<Long, Int>()
|
||||
blocks.forEach { block ->
|
||||
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
||||
key(block.event.eventId, ordinal) {
|
||||
val laneWidth = colWidth / block.laneCount
|
||||
val top = hourHeight * (block.startMin / 60f)
|
||||
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||
val place = animatedBlockPlacement(
|
||||
x = laneWidth * block.lane,
|
||||
y = top,
|
||||
width = laneWidth,
|
||||
height = height,
|
||||
)
|
||||
// Where this block is cut at midnight, which decides both
|
||||
// its shape and the half-gap it gives up to the hour cells.
|
||||
val cuts = remember(block, date, zone) {
|
||||
timedBlockCuts(
|
||||
block.continuesBefore(date, zone),
|
||||
block.continuesAfter(date, zone),
|
||||
)
|
||||
}
|
||||
val topInset = hourCellBlockInset(showHourGrid, cuts.top)
|
||||
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
|
||||
val blockHeight = (place.height - topInset - bottomInset)
|
||||
.coerceAtLeast(0.dp)
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = blockHeight,
|
||||
width = place.width,
|
||||
date = date,
|
||||
cuts = cuts,
|
||||
topInset = topInset,
|
||||
dragController = dragController,
|
||||
onClick = { onEventClick(block.event) },
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.offset(x = place.x, y = place.y + topInset)
|
||||
.width(place.width)
|
||||
.height(blockHeight)
|
||||
.padding(horizontal = BLOCK_OUTER_INSET),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Current-time line, on top of the events, only on today's column.
|
||||
if (date == today) {
|
||||
NowLine(date = date, hourHeight = hourHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventBlock(
|
||||
block: TimedBlock,
|
||||
dark: Boolean,
|
||||
height: Dp,
|
||||
width: Dp,
|
||||
date: LocalDate,
|
||||
cuts: ChipCuts,
|
||||
topInset: Dp,
|
||||
dragController: TimelineDragController,
|
||||
onClick: () -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||
minToHm(block.endMin, use24Hour, locale)
|
||||
val density = LocalDensity.current
|
||||
val titleLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||
}
|
||||
val timeLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||
}
|
||||
// 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.
|
||||
// Height alone decides: a duration threshold would keep hiding the time on a
|
||||
// half-hour block the user has pinched open to three times the room it needs.
|
||||
val available = height - BLOCK_TEXT_INSET * 2
|
||||
val showTime = available >= titleLineHeight + timeLineHeight
|
||||
val showTitle = available >= titleLineHeight
|
||||
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
||||
// Only lines the block can actually draw: a block too short for the time is
|
||||
// too short for a second title line too, and asking for one served a sliced
|
||||
// one — as well as handing the drag copy a count it couldn't honour, so the
|
||||
// title re-wrapped the moment the block was lifted (#267).
|
||||
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(1)
|
||||
val titleMaxLines = if (showTime) 1 else titleBudget.coerceAtMost(2)
|
||||
// On a day column — wide enough for "09:30–11:00" several times over — the
|
||||
// range never needs the second line, until lanes cut the column down.
|
||||
val spare = available - titleLineHeight * titleMaxLines -
|
||||
if (showTime) timeLineHeight else 0.dp
|
||||
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
|
||||
val paint = eventPaint(block.event, dark)
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val moveAction = eventMoveAction(block.event)
|
||||
val draggable = eventDragAllowed(block.event)
|
||||
// The drop takes this offset back off, so a tail clipped at midnight lands
|
||||
// where the event's own start belongs (#253).
|
||||
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
|
||||
val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
|
||||
val topInsetPx = with(density) { topInset.toPx() }
|
||||
val dragModifier = rememberEventDragSource(
|
||||
enabled = draggable,
|
||||
key = block.event.instanceId,
|
||||
onPickUp = { pointer, blockRoot, _ ->
|
||||
dragController.begin(
|
||||
block = block,
|
||||
clipOffsetMin = clipOffset,
|
||||
titleLines = if (showTitle) titleMaxLines else 0,
|
||||
pointerInRoot = pointer,
|
||||
// Measured off the block's placement rather than off where the
|
||||
// hour grid seats it: the copy is drawn from that placement and
|
||||
// re-seated the same half-gap down (#267).
|
||||
blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx),
|
||||
)
|
||||
},
|
||||
onMove = dragController::move,
|
||||
onDrop = { dragController.finish()?.let(onDrop) },
|
||||
onCancel = dragController::cancel,
|
||||
)
|
||||
val lifted = draggable && dragController.ghosts(block)
|
||||
val ghost = ghostAlpha(lifted)
|
||||
Box(
|
||||
modifier = modifier
|
||||
// The source stays put as a ghost while its floating copy travels.
|
||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||
.eventSurface(paint, shape, cuts)
|
||||
.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 = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET)
|
||||
.semantics {
|
||||
contentDescription = "$title, $timeLabel"
|
||||
if (moveAction != null) customActions = listOf(moveAction)
|
||||
},
|
||||
) {
|
||||
Column {
|
||||
if (showTitle) {
|
||||
BlockTitle(
|
||||
title = title,
|
||||
maxLines = titleMaxLines,
|
||||
color = paint.titleInk,
|
||||
textDecoration = paint.decoration,
|
||||
fontWeight = paint.titleWeight,
|
||||
)
|
||||
}
|
||||
if (showTime) {
|
||||
BlockTimeLabel(
|
||||
label = timeLabel,
|
||||
color = paint.secondaryInk,
|
||||
maxLines = timeMaxLines,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayLoading() {
|
||||
val scale = LocalTimelineZoom.current.scale
|
||||
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 hourHeight = scale.hourHeight(maxHeight)
|
||||
val totalHeight = hourHeight * 24
|
||||
// The skeleton wears the loaded column's own ground, so the arrival of
|
||||
// the real day doesn't flash a solid block into a gapped grid.
|
||||
val showHourGrid = LocalShowHourGrid.current
|
||||
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(totalHeight)
|
||||
.padding(horizontal = 2.dp)
|
||||
.background(
|
||||
if (showHourGrid) MaterialTheme.colorScheme.surface
|
||||
else MaterialTheme.colorScheme.surfaceContainer,
|
||||
)
|
||||
.hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
||||
formatMinuteOfDay(min, is24Hour, locale)
|
||||
|
||||
/** [abbreviated] drops the weekday, leaving the date itself (#165). */
|
||||
private fun formatDayTitle(
|
||||
date: LocalDate,
|
||||
locale: Locale,
|
||||
currentYear: Int,
|
||||
abbreviated: Boolean = false,
|
||||
): String =
|
||||
formatCalendarTitle(
|
||||
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
|
||||
locale = locale,
|
||||
currentYear = currentYear,
|
||||
skeleton = if (abbreviated) "dMMM" else "EEEdMMM",
|
||||
)
|
||||
@@ -1,25 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.ui.week.AllDaySpan
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* The day view is a single-column slice of the week view (spec S3). It reuses the
|
||||
* week's [TimedBlock] and [AllDaySpan] layout primitives — for one day, all-day
|
||||
* spans collapse to a single column ([AllDaySpan.startCol] == [AllDaySpan.endCol]
|
||||
* == 0) and only their [AllDaySpan.lane] (vertical stacking) matters.
|
||||
*/
|
||||
sealed interface DayUiState {
|
||||
data object Loading : DayUiState
|
||||
data class Failure(val reason: FailureReason) : DayUiState
|
||||
data class Success(
|
||||
val date: LocalDate,
|
||||
val today: LocalDate,
|
||||
/** All-day/multi-day events covering this day, stacked by lane. */
|
||||
val allDay: List<AllDaySpan>,
|
||||
/** Timed events clipped to this day with overlap lanes resolved. */
|
||||
val timed: List<TimedBlock>,
|
||||
) : DayUiState
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
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.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
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.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class DayViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
|
||||
private val todayDate: LocalDate
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
private val _date = MutableStateFlow(todayDate)
|
||||
val date: StateFlow<LocalDate> = _date
|
||||
|
||||
val state: StateFlow<DayUiState> = _date
|
||||
.flatMapLatest { day ->
|
||||
val range = dayRange(day, zone)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
) { calendars, instances ->
|
||||
buildState(day, calendars, instances)
|
||||
}
|
||||
}
|
||||
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = DayUiState.Loading,
|
||||
)
|
||||
|
||||
fun goToPrev() {
|
||||
_date.value = _date.value.minus(1, DateTimeUnit.DAY)
|
||||
}
|
||||
|
||||
fun goToNext() {
|
||||
_date.value = _date.value.plus(1, DateTimeUnit.DAY)
|
||||
}
|
||||
|
||||
fun goToToday() {
|
||||
_date.value = todayDate
|
||||
}
|
||||
|
||||
/** Jump to a specific date (e.g. when opened from the month grid). */
|
||||
fun goToDate(date: LocalDate) {
|
||||
_date.value = date
|
||||
}
|
||||
|
||||
private fun buildState(
|
||||
day: LocalDate,
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): DayUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return DayUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
val days = listOf(day)
|
||||
val allDay = instances.filter { it.isAllDay }
|
||||
val timed = instances.filterNot { it.isAllDay }
|
||||
return DayUiState.Success(
|
||||
date = day,
|
||||
today = todayDate,
|
||||
allDay = layoutAllDay(allDay, days, zone),
|
||||
timed = layoutDay(timed, day, zone),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Half-open instant range covering the single calendar [date]. */
|
||||
internal fun dayRange(date: LocalDate, zone: TimeZone): ClosedRange<Instant> {
|
||||
val from = date.atStartOfDayIn(zone)
|
||||
val to = date.atTime(23, 59, 59).toInstant(zone)
|
||||
return from..to
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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,
|
||||
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),
|
||||
)
|
||||
}
|
||||
@@ -140,6 +140,7 @@ fun EventDetailScreen(
|
||||
onBack: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onDuplicate: (EventForm) -> Unit,
|
||||
visible: Boolean = true,
|
||||
viewModel: EventDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(eventId, beginMillis, endMillis) {
|
||||
@@ -227,7 +228,7 @@ fun EventDetailScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.predictiveBack(onBack = onBack),
|
||||
modifier = Modifier.predictiveBack(onBack = onBack, visible = visible),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -302,6 +303,7 @@ fun EventDetailScreen(
|
||||
is EventDetailUiState.Failure -> CalendarFailure(
|
||||
reason = s.reason,
|
||||
onRetry = viewModel::retry,
|
||||
modifier = contentModifier,
|
||||
)
|
||||
is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier)
|
||||
}
|
||||
@@ -526,7 +528,8 @@ private fun EventDetailContent(
|
||||
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
||||
) {
|
||||
Text(
|
||||
text = state.calendarName ?: stringResource(R.string.event_detail_calendar_unknown),
|
||||
text = state.calendarName?.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||
?: stringResource(R.string.event_detail_calendar_unknown),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -203,6 +203,7 @@ fun EventEditScreen(
|
||||
initialForm: EventForm? = null,
|
||||
initialFormSource: ImportSource = ImportSource.File,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
visible: Boolean = true,
|
||||
viewModel: EventEditViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
||||
@@ -285,7 +286,7 @@ fun EventEditScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.predictiveBack(onBack = close),
|
||||
modifier = Modifier.predictiveBack(onBack = close, visible = visible),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -620,7 +621,9 @@ private fun EventEditContent(
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.event_edit_managed_hint,
|
||||
selectedCalendar?.displayName.orEmpty(),
|
||||
selectedCalendar?.displayName
|
||||
?.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||
.orEmpty(),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -719,6 +722,7 @@ private fun EventEditContent(
|
||||
) {
|
||||
Text(
|
||||
text = selectedCalendar?.displayName
|
||||
?.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||
?: stringResource(R.string.event_edit_error_no_calendar),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (selectedCalendar == null) {
|
||||
|
||||
@@ -62,15 +62,16 @@ private fun FilterList(
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
groups.forEach { group ->
|
||||
val account = group.account.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||
Text(
|
||||
text = if (group.ambiguous) {
|
||||
stringResource(
|
||||
R.string.calendars_account_from_source,
|
||||
group.account,
|
||||
account,
|
||||
sourceAppName(group.accountType),
|
||||
)
|
||||
} else {
|
||||
group.account
|
||||
account
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -83,7 +84,7 @@ private fun FilterList(
|
||||
)
|
||||
group.calendars.forEachIndexed { index, cal ->
|
||||
GroupedRow(
|
||||
title = cal.displayName,
|
||||
title = cal.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
position = positionOf(index, group.calendars.size),
|
||||
minHeight = 56.dp,
|
||||
leading = { CalendarColorChip(cal.color) },
|
||||
@@ -125,6 +126,7 @@ private fun FilterLoading(modifier: Modifier = Modifier) {
|
||||
private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier) {
|
||||
val msg = when (reason) {
|
||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
||||
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden
|
||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
||||
else -> R.string.state_failure_provider
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -35,8 +36,9 @@ class FilterViewModel @Inject constructor(
|
||||
// Calendars switched off device-wide don't belong in the drawer's
|
||||
// hide/show list; they live only in Settings → Calendars.
|
||||
val enabled = calendars.filter { it.isVisibleInSystem }
|
||||
if (enabled.isEmpty()) {
|
||||
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
val failure = calendarListFailure(calendars)
|
||||
if (failure != null) {
|
||||
FilterUiState.Failure(failure)
|
||||
} else {
|
||||
FilterUiState.Success(groupCalendarsForFilter(enabled, hidden))
|
||||
}
|
||||
|
||||
@@ -73,19 +73,20 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
@Composable
|
||||
fun ImportScreen(
|
||||
uri: Uri,
|
||||
session: Int,
|
||||
onClose: () -> Unit,
|
||||
onOpenSingle: (EventForm) -> Unit,
|
||||
forceMany: Boolean = false,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
// Key the VM by the file uri. This screen has no nav backstack, so an
|
||||
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
|
||||
// across imports — its one-shot `load` guard would then show the *previous*
|
||||
// file's parsed state on the next import (a second restore, export→restore,
|
||||
// etc.). Keying per uri hands each distinct file a fresh VM (fresh Loading
|
||||
// state), while the same uri (rotation) reuses it and holds the result.
|
||||
viewModel: ImportViewModel = hiltViewModel(key = uri.toString()),
|
||||
visible: Boolean = true,
|
||||
viewModel: ImportViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(uri) { viewModel.load(uri, forceMany) }
|
||||
// hiltViewModel() resolves to the Activity's store and is retained across
|
||||
// imports, so the reload is driven by [session] rather than by a fresh VM:
|
||||
// keying per uri looked right but handed a *re-import of the same file* the
|
||||
// previous run's finished state, importing nothing (#304). A rotation keeps
|
||||
// the session and so keeps the result.
|
||||
LaunchedEffect(session) { viewModel.load(uri, forceMany, session) }
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
// A single event isn't shown here — it opens the create form for review.
|
||||
@@ -103,7 +104,7 @@ fun ImportScreen(
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.predictiveBack(onBack = onClose)
|
||||
.predictiveBack(onBack = onClose, visible = visible)
|
||||
.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
|
||||
@@ -16,6 +16,7 @@ import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -69,18 +70,26 @@ class ImportViewModel @Inject constructor(
|
||||
private val parser = IcsParser()
|
||||
private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Loading)
|
||||
val state: StateFlow<ImportUiState> = _state.asStateFlow()
|
||||
private var started = false
|
||||
private var loadedSession: Int? = null
|
||||
private var loadJob: Job? = null
|
||||
|
||||
/**
|
||||
* Read + parse [uri] once; subsequent calls (recomposition) are ignored.
|
||||
* Read + parse [uri] once per [session]; recomposition (and a rotation,
|
||||
* which keeps the session) re-calls this and keeps the result. A new import
|
||||
* of the *same* file is a new session, and has to parse and run again —
|
||||
* keying on the uri alone showed the previous run's summary and imported
|
||||
* nothing (#304).
|
||||
*
|
||||
* When [forceMany] is set (an in-app restore), a single-event file still goes
|
||||
* through the bulk picker + summary rather than the prefilled create form —
|
||||
* a restore is "bring back a backup", not "add this one event".
|
||||
*/
|
||||
fun load(uri: Uri, forceMany: Boolean = false) {
|
||||
if (started) return
|
||||
started = true
|
||||
viewModelScope.launch {
|
||||
fun load(uri: Uri, forceMany: Boolean = false, session: Int = 0) {
|
||||
if (loadedSession == session) return
|
||||
loadedSession = session
|
||||
loadJob?.cancel()
|
||||
_state.value = ImportUiState.Loading
|
||||
loadJob = viewModelScope.launch {
|
||||
val parsed = withContext(io) {
|
||||
importer.readText(uri)?.let(parser::parse)
|
||||
}
|
||||
|
||||
@@ -23,13 +23,14 @@ import de.jeanlucmakiola.floret.components.positionOf
|
||||
* what the *recipient of the binary* is told. Each row opens the project's source.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LicencesScreen(onBack: () -> Unit) {
|
||||
internal fun LicencesScreen(onBack: () -> Unit, visible: Boolean = true) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.licences_title),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.licences_intro),
|
||||
|
||||
@@ -10,10 +10,16 @@ import androidx.compose.ui.unit.dp
|
||||
internal val CELL_GAP = 2.dp
|
||||
|
||||
/** Padding between a month chip's edge and its text. */
|
||||
internal val MONTH_CHIP_TEXT_PADDING = 4.dp
|
||||
internal val MONTH_CHIP_TEXT_PADDING = 3.dp
|
||||
|
||||
/** A chip's own inset inside its day cell, on top of the cell's gap. */
|
||||
internal val MONTH_CHIP_INSET = CELL_GAP + 1.dp
|
||||
/**
|
||||
* A chip's own inset inside its day cell. The cell's gap and no more: a day
|
||||
* column on a phone is around fifty dp, and the chip was spending a quarter of
|
||||
* it on chrome before a single glyph. The cells keep their full separation from
|
||||
* each other — what the grid reads as breathing room — and only the chip inside
|
||||
* one takes the width back (#212).
|
||||
*/
|
||||
internal val MONTH_CHIP_INSET = CELL_GAP
|
||||
|
||||
/** Horizontal space a chip spends on chrome rather than on text, both sides. */
|
||||
internal val MONTH_CHIP_CHROME = (MONTH_CHIP_INSET + MONTH_CHIP_TEXT_PADDING) * 2f
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,10 @@ import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
|
||||
import de.jeanlucmakiola.calendula.ui.week.coversDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
|
||||
@@ -24,6 +27,9 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.filterNot
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlin.math.abs
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
@@ -43,6 +49,9 @@ import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
/** How many months either side of the requested one [MonthViewModel.month] keeps cached. */
|
||||
private const val MONTH_CACHE_SPAN = 2
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class MonthViewModel @Inject constructor(
|
||||
@@ -86,6 +95,21 @@ class MonthViewModel @Inject constructor(
|
||||
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
|
||||
val month: StateFlow<YearMonth> = _month
|
||||
|
||||
/** The pager page the anchor month sits on, for the paged and split styles. */
|
||||
val anchorPage: StateFlow<Int> = _month
|
||||
.map { monthIndexOf(it) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = monthIndexOf(_month.value),
|
||||
)
|
||||
|
||||
/**
|
||||
* The anchor month's state. Once loaded it never falls back to
|
||||
* [MonthUiState.Loading]: moving the anchor keeps the last result until the
|
||||
* new month arrives, since the pages draw their own months and this feeds
|
||||
* the failure screen and the split style's day pane.
|
||||
*/
|
||||
val state: StateFlow<MonthUiState> =
|
||||
combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(ym, ws, style) }
|
||||
.flatMapLatest { (ym, ws, style) ->
|
||||
@@ -93,22 +117,48 @@ class MonthViewModel @Inject constructor(
|
||||
// Split draw, so under a scrolling style this query is a month
|
||||
// of provider work for a view that isn't on screen.
|
||||
if (style.isScrolling) return@flatMapLatest flowOf(MonthUiState.Loading)
|
||||
val range = monthGridRange(ym, ws, zone)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
) { calendars, instances ->
|
||||
buildState(ym, ws, calendars, instances)
|
||||
}
|
||||
month(ym, ws).filterNot { it is MonthUiState.Loading }
|
||||
}
|
||||
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = MonthUiState.Loading,
|
||||
)
|
||||
|
||||
private val months = PageStateCache<Pair<YearMonth, DayOfWeek>, MonthUiState>(
|
||||
scope = viewModelScope,
|
||||
initial = MonthUiState.Loading,
|
||||
// Far enough to cover the pages either side and a swipe back, so paging
|
||||
// through a year doesn't keep a year of queries around. A week-start
|
||||
// change lays every month out afresh.
|
||||
keep = { (cached, cachedWs), (requested, ws) ->
|
||||
cachedWs == ws && abs(monthIndexOf(cached) - monthIndexOf(requested)) <= MONTH_CACHE_SPAN
|
||||
},
|
||||
) { (ym, ws) ->
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(monthGridRange(ym, ws, zone)),
|
||||
) { calendars, instances ->
|
||||
buildState(ym, ws, calendars, instances)
|
||||
}
|
||||
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
}
|
||||
|
||||
/**
|
||||
* Month [ym] laid out with weeks starting on [ws], shared between its pager
|
||||
* page and [state]. Main thread only.
|
||||
*/
|
||||
fun month(ym: YearMonth, ws: DayOfWeek): StateFlow<MonthUiState> = months.get(ym to ws)
|
||||
|
||||
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
|
||||
fun onPageSettled(page: Int) {
|
||||
if (monthIndexOf(_month.value) == page) return
|
||||
_month.value = yearMonthForIndex(page)
|
||||
realignSelection()
|
||||
}
|
||||
|
||||
// --- Continuous + Dense styles (#38) ----------------------------------
|
||||
//
|
||||
// These scroll through every month there is, so they can't load "a month" —
|
||||
@@ -208,9 +258,7 @@ class MonthViewModel @Inject constructor(
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): ContinuousMonthUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
calendarListFailure(calendars)?.let { return ContinuousMonthUiState.Failure(it) }
|
||||
// Bucketed once for the whole window: a row then takes the handful of
|
||||
// events on its seven days instead of re-scanning a year of them.
|
||||
val byDay = DayIndex(instances, zone)
|
||||
@@ -245,12 +293,10 @@ class MonthViewModel @Inject constructor(
|
||||
|
||||
// --- Split style (#53) ------------------------------------------------
|
||||
//
|
||||
// The first selected-day concept in the app: the other views drill straight
|
||||
// into a date, while the split style keeps one selected and lists it below
|
||||
// the grid.
|
||||
// Null means the full-month agenda; a date narrows the pane to that day.
|
||||
|
||||
private val _selectedDate = MutableStateFlow(todayDate)
|
||||
val selectedDate: StateFlow<LocalDate> = _selectedDate
|
||||
private val _selectedDate = MutableStateFlow<LocalDate?>(null)
|
||||
val selectedDate: StateFlow<LocalDate?> = _selectedDate
|
||||
|
||||
/**
|
||||
* Select [date], following it to its month if it sits in the grid's leading
|
||||
@@ -258,33 +304,33 @@ class MonthViewModel @Inject constructor(
|
||||
* silently list a date the grid isn't pointing at.
|
||||
*/
|
||||
fun selectDate(date: LocalDate) {
|
||||
_selectedDate.value = date
|
||||
val target = YearMonth(date.year, date.month)
|
||||
_selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date
|
||||
if (target != _month.value) _month.value = target
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the selection with the month: today if the new month holds it, else
|
||||
* its 1st. Leaving the old date selected would list a day the grid no longer
|
||||
* shows.
|
||||
*/
|
||||
fun clearSelection() { _selectedDate.value = null }
|
||||
|
||||
/** A pane swipe walks days when filtered, months when showing the agenda. */
|
||||
fun stepSplitPane(days: Int) {
|
||||
val selected = _selectedDate.value
|
||||
if (selected == null) {
|
||||
_month.value = yearMonthForIndex(monthIndexOf(_month.value) + days)
|
||||
} else {
|
||||
val next = selected.plus(days, DateTimeUnit.DAY)
|
||||
_selectedDate.value = next
|
||||
_month.value = YearMonth(next.year, next.month)
|
||||
}
|
||||
}
|
||||
|
||||
/** Moving the grid to another month restores its full agenda. */
|
||||
private fun realignSelection() {
|
||||
_selectedDate.value = selectionForMonth(_month.value, todayDate)
|
||||
}
|
||||
|
||||
fun goToPrev() {
|
||||
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
||||
realignSelection()
|
||||
}
|
||||
|
||||
fun goToNext() {
|
||||
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
|
||||
realignSelection()
|
||||
_selectedDate.value = null
|
||||
}
|
||||
|
||||
fun goToToday() {
|
||||
_month.value = YearMonth(todayDate.year, todayDate.month)
|
||||
_selectedDate.value = todayDate
|
||||
_selectedDate.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -293,13 +339,12 @@ class MonthViewModel @Inject constructor(
|
||||
* otherwise sit wherever paged navigation last left it (today's month, on a
|
||||
* fresh open) — and a switch to a paged style, or a reseed of the other
|
||||
* scrolling style's list, would jump there instead of holding position. The
|
||||
* selection is realigned alongside it so that landing on the Split style
|
||||
* shows a live day in the month on screen rather than a stale, off-month one.
|
||||
* selection is cleared alongside it so Split opens the visible month's agenda.
|
||||
*/
|
||||
fun syncScrollMonth(ym: YearMonth) {
|
||||
if (_month.value == ym) return
|
||||
_month.value = ym
|
||||
_selectedDate.value = selectionForMonth(ym, todayDate)
|
||||
_selectedDate.value = null
|
||||
}
|
||||
|
||||
/** Jump to the month containing [date] (drawer jump-to-date). */
|
||||
@@ -314,9 +359,7 @@ class MonthViewModel @Inject constructor(
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): MonthUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
calendarListFailure(calendars)?.let { return MonthUiState.Failure(it) }
|
||||
val weeks = layoutMonthWeeks(ym, weekStart, instances, zone)
|
||||
return MonthUiState.Success(
|
||||
month = ym,
|
||||
@@ -464,11 +507,7 @@ internal fun instancesByDay(
|
||||
days.associateWith { day ->
|
||||
instances
|
||||
.filter { it.coversDay(day, zone) }
|
||||
.sortedWith(
|
||||
compareBy<EventInstance> { it.isDeclined }
|
||||
.thenByDescending { it.isAllDay }
|
||||
.thenBy { it.start },
|
||||
)
|
||||
.sortedWith(eventDisplayComparator(zone))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -509,11 +548,10 @@ private const val WINDOW_EDGE = 2
|
||||
internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLeast(0)
|
||||
|
||||
/**
|
||||
* Which day the split style should select when the grid lands on [month]:
|
||||
* [today] when the month holds it, otherwise the 1st. Pure so the rule can be
|
||||
* tested without standing up a view model and a provider behind it.
|
||||
* First day shown in the split agenda: today in the current month, otherwise
|
||||
* the first of the visible month.
|
||||
*/
|
||||
internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate =
|
||||
internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate =
|
||||
if (YearMonth(today.year, today.month) == month) {
|
||||
today
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
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.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.CloudSync
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarEditor
|
||||
import de.jeanlucmakiola.calendula.ui.settings.openUrl
|
||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
|
||||
/** Where the sync branch sends someone who needs a CalDAV client (#287). */
|
||||
private const val DAVX5_URL = "https://www.davx5.com/"
|
||||
|
||||
/**
|
||||
* Wizard step shown only when the device has no calendar at all (#287): the app
|
||||
* would otherwise finish onboarding onto an empty grid with nothing saying why.
|
||||
*
|
||||
* Two ways out. Calendula writes to the system calendar provider and syncs
|
||||
* nothing itself, so anything beyond a device-only calendar means pointing at
|
||||
* whatever does the syncing.
|
||||
*
|
||||
* [creation] switches the step to the outcome of its own create: confirming
|
||||
* the calendar — the one place to say that more of them live in Settings — or
|
||||
* reporting that the provider refused it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun CalendarsStep(
|
||||
onCreateLocal: (name: String, color: Int, description: String?) -> Unit,
|
||||
onSkip: () -> Unit,
|
||||
creation: CalendarCreation?,
|
||||
onFinished: () -> Unit,
|
||||
onClearCreation: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
// The same editor Settings opens, rather than a calendar conjured out of
|
||||
// nowhere: "create a calendar" should let you name and colour it, and the
|
||||
// step is the first place anyone meets one.
|
||||
var creating by rememberSaveable { mutableStateOf(false) }
|
||||
// The insert is a suspend write: the editor stays up until it lands, so the
|
||||
// step can't flash its "create" button back for a second press.
|
||||
var submitted by rememberSaveable(creating) { mutableStateOf(false) }
|
||||
LaunchedEffect(creation) { if (creation is CalendarCreation.Failed) creating = false }
|
||||
|
||||
if (creation is CalendarCreation.Created) {
|
||||
CalendarCreated(
|
||||
name = creation.name,
|
||||
onContinue = onFinished,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (creating) {
|
||||
CalendarEditor(
|
||||
sessionKey = 0,
|
||||
isNew = true,
|
||||
// Empty, not a suggestion: a name already in the field reads as one
|
||||
// the app has chosen, and the first thing to do is clear it.
|
||||
initialName = "",
|
||||
initialColor = CalendarColorPalette.all.first(),
|
||||
initialDescription = "",
|
||||
onSave = { name, color, description ->
|
||||
if (!submitted) {
|
||||
submitted = true
|
||||
onCreateLocal(name, color, description)
|
||||
}
|
||||
},
|
||||
onDelete = {},
|
||||
onClose = { creating = false },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { IconHero(Icons.Filled.CalendarMonth) },
|
||||
actions = {
|
||||
Button(
|
||||
onClick = {
|
||||
onClearCreation()
|
||||
creating = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_calendars_local_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { openUrl(context, DAVX5_URL) },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.onboarding_calendars_caldav_button))
|
||||
}
|
||||
TextButton(
|
||||
onClick = onSkip,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.onboarding_calendars_skip_button))
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_calendars_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_calendars_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
if (creation is CalendarCreation.Failed) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_calendars_create_failed),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||
|
||||
BenefitRow(
|
||||
icon = Icons.Filled.PhoneAndroid,
|
||||
title = stringResource(R.string.onboarding_calendars_benefit_local_title),
|
||||
body = stringResource(R.string.onboarding_calendars_benefit_local_body),
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||
BenefitRow(
|
||||
icon = Icons.Filled.CloudSync,
|
||||
title = stringResource(R.string.onboarding_calendars_benefit_sync_title),
|
||||
body = stringResource(R.string.onboarding_calendars_benefit_sync_body),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** What the step shows once the calendar exists: that it does, and where the next one comes from. */
|
||||
@Composable
|
||||
private fun CalendarCreated(
|
||||
name: String,
|
||||
onContinue: () -> 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.CheckCircle) },
|
||||
actions = {
|
||||
Button(
|
||||
onClick = onContinue,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_calendars_created_continue),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_calendars_created_title, name),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_calendars_created_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||
|
||||
BenefitRow(
|
||||
icon = Icons.Filled.Settings,
|
||||
title = stringResource(R.string.onboarding_calendars_created_more_title),
|
||||
body = stringResource(R.string.onboarding_calendars_created_more_body),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
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.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/**
|
||||
* Wizard step for a device whose every calendar is switched off (#287), which
|
||||
* would otherwise open the app on a blank grid with nothing saying why.
|
||||
*
|
||||
* The calendars are listed rather than summarised: which ones exist and whose
|
||||
* account they came from is the whole of what there is to decide, and switching
|
||||
* them on shows them in every other calendar app too — not a choice to make on
|
||||
* the app's word. Each row carries the same switch Settings does; the button
|
||||
* above them does all of it at once.
|
||||
*
|
||||
* Declining leads into the calendar step behind this one, which offers to make a
|
||||
* calendar instead — the same offer a device with none at all gets.
|
||||
*/
|
||||
@Composable
|
||||
internal fun HiddenCalendarsStep(
|
||||
calendars: List<CalendarSource>,
|
||||
onSetVisible: (id: Long, visible: Boolean) -> Unit,
|
||||
onSwitchAllOn: () -> Unit,
|
||||
onFinished: () -> Unit,
|
||||
writeFailed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val anyVisible = calendars.any { it.isVisibleInSystem }
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
topSpacing = OnboardingSpace.sm,
|
||||
contentPadding = 0.dp,
|
||||
// The list is as long as the device's calendars; pinned actions would
|
||||
// eat the room it needs and hide that there is more below.
|
||||
scrollingActions = true,
|
||||
hero = { IconHero(Icons.Filled.VisibilityOff) },
|
||||
actions = {
|
||||
Button(
|
||||
onClick = if (anyVisible) onFinished else onSwitchAllOn,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = GroupedListInset)
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (anyVisible) {
|
||||
R.string.onboarding_view_continue_button
|
||||
} else {
|
||||
R.string.onboarding_hidden_switch_all_button
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
// Only while nothing is on: once something is, the step has been
|
||||
// answered and the one button left to press says so.
|
||||
if (!anyVisible) {
|
||||
TextButton(
|
||||
onClick = onFinished,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = GroupedListInset)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.onboarding_hidden_skip_button))
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = GroupedListInset)) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_hidden_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_hidden_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (writeFailed) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_hidden_failed),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(OnboardingSpace.md))
|
||||
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
val off = !calendar.isVisibleInSystem
|
||||
GroupedRow(
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
summary = calendar.accountName.takeIf { it.isNotBlank() },
|
||||
position = when {
|
||||
calendars.size == 1 -> Position.Alone
|
||||
index == 0 -> Position.Top
|
||||
index == calendars.lastIndex -> Position.Bottom
|
||||
else -> Position.Middle
|
||||
},
|
||||
dimmed = off,
|
||||
trailing = if (calendar.hasVisibilitySwitch) {
|
||||
{
|
||||
CalendarSwitch(
|
||||
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
enabled = !off,
|
||||
onToggle = { on -> onSetVisible(calendar.id, on) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(OnboardingSpace.md))
|
||||
}
|
||||
}
|
||||
|
||||
/** The per-row switch, writing the device's own `Calendars.VISIBLE` as Settings does. */
|
||||
@Composable
|
||||
private fun CalendarSwitch(
|
||||
calendarName: String,
|
||||
enabled: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = onToggle,
|
||||
modifier = Modifier.semantics { contentDescription = label },
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,19 @@ enum class OnboardingStep {
|
||||
/** Required: nothing works without the calendar grant. */
|
||||
Permission,
|
||||
|
||||
/**
|
||||
* Only when every calendar on the device is switched off, so the app would
|
||||
* open on a blank grid: lists them and offers to switch them back on (#287).
|
||||
*/
|
||||
HiddenCalendars,
|
||||
|
||||
/**
|
||||
* Only when the device has nothing to put an event in — no calendar at all,
|
||||
* or none left showing after the step above. The app can make a local one,
|
||||
* or send you to whatever would do the syncing (#287).
|
||||
*/
|
||||
Calendars,
|
||||
|
||||
/** Whether Calendula delivers reminder notifications itself. */
|
||||
Reminders,
|
||||
|
||||
@@ -67,6 +80,18 @@ data class OnboardingPlan(
|
||||
* otherwise, so the flow can only ever get shorter — never sprout a step the
|
||||
* counter had not accounted for.
|
||||
*
|
||||
* [calendarsApplies] carries the same contract for the calendar step, with one
|
||||
* wrinkle: for the backup step an empty list means "could not read", but here
|
||||
* an empty list *is* the condition. The two are indistinguishable at this
|
||||
* layer and both want the step, so it turns false only once a calendar the app
|
||||
* could actually show has arrived.
|
||||
*
|
||||
* [hiddenApplies] is the step in front of it (#287): the device has calendars
|
||||
* but every one of them is switched off. It comes first because switching one
|
||||
* back on is the lighter remedy — and where it is declined, the calendar step
|
||||
* behind it still offers to make one, the same offer a device with no calendars
|
||||
* at all gets.
|
||||
*
|
||||
* 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
|
||||
@@ -75,7 +100,9 @@ data class OnboardingPlan(
|
||||
* [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.
|
||||
* screen. It stays a pure announcement: the all-hidden *state* is
|
||||
* [hiddenApplies]'s business, up at the front of the flow where something can
|
||||
* still be done about it.
|
||||
*/
|
||||
fun onboardingPlan(
|
||||
hasPermission: Boolean,
|
||||
@@ -85,6 +112,10 @@ fun onboardingPlan(
|
||||
viewDone: Boolean,
|
||||
monthStyleDone: Boolean,
|
||||
backupApplies: Boolean?,
|
||||
hiddenDone: Boolean = false,
|
||||
hiddenApplies: Boolean? = false,
|
||||
calendarsDone: Boolean = false,
|
||||
calendarsApplies: Boolean? = null,
|
||||
visibilityArmed: Boolean = false,
|
||||
visibilityDone: Boolean = false,
|
||||
doneShown: Boolean = false,
|
||||
@@ -92,6 +123,8 @@ fun onboardingPlan(
|
||||
val fresh = wizardArmed || (!hasPermission && !remindersDone)
|
||||
val steps = buildList {
|
||||
if (!hasPermission || fresh) add(OnboardingStep.Permission)
|
||||
if (fresh && hiddenApplies != false) add(OnboardingStep.HiddenCalendars)
|
||||
if (fresh && calendarsApplies != false) add(OnboardingStep.Calendars)
|
||||
if (!remindersDone || fresh) add(OnboardingStep.Reminders)
|
||||
if (fresh && backupApplies != false) add(OnboardingStep.Backup)
|
||||
if (fresh) {
|
||||
@@ -104,6 +137,8 @@ fun onboardingPlan(
|
||||
val current = steps.firstOrNull { step ->
|
||||
when (step) {
|
||||
OnboardingStep.Permission -> !hasPermission
|
||||
OnboardingStep.HiddenCalendars -> !hiddenDone
|
||||
OnboardingStep.Calendars -> !calendarsDone
|
||||
OnboardingStep.Reminders -> !remindersDone
|
||||
OnboardingStep.Backup -> !backupDone
|
||||
OnboardingStep.View -> !viewDone
|
||||
|
||||
@@ -28,6 +28,9 @@ fun OnboardingSteps(
|
||||
// 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()
|
||||
val creation by viewModel.creation.collectAsStateWithLifecycle()
|
||||
val hiddenCalendars by viewModel.hiddenCalendars.collectAsStateWithLifecycle()
|
||||
val hiddenWriteFailed by viewModel.hiddenWriteFailed.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
|
||||
@@ -67,6 +70,26 @@ fun OnboardingSteps(
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
)
|
||||
OnboardingStep.HiddenCalendars -> HiddenCalendarsStep(
|
||||
calendars = hiddenCalendars,
|
||||
onSetVisible = viewModel::setHiddenCalendarVisible,
|
||||
onSwitchAllOn = viewModel::switchAllCalendarsOn,
|
||||
onFinished = viewModel::finishHiddenCalendars,
|
||||
writeFailed = hiddenWriteFailed,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
)
|
||||
OnboardingStep.Calendars -> CalendarsStep(
|
||||
onCreateLocal = viewModel::createLocalCalendar,
|
||||
onSkip = viewModel::skipCalendars,
|
||||
creation = creation,
|
||||
onFinished = viewModel::finishCalendars,
|
||||
onClearCreation = viewModel::clearCalendarCreation,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
)
|
||||
OnboardingStep.Reminders -> ReminderStep(
|
||||
onFinished = viewModel::finishReminders,
|
||||
modifier = modifier,
|
||||
|
||||
+235
-10
@@ -16,6 +16,8 @@ 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.domain.hasVisibilitySwitch
|
||||
import de.jeanlucmakiola.calendula.domain.orderedForManager
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -24,6 +26,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
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.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -74,6 +77,27 @@ class OnboardingViewModel @Inject constructor(
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* The calendar list every derived step reads, collected once: one per step
|
||||
* would put a provider query each on every visibility tick, and let the
|
||||
* steps judge different snapshots of the same moment. Null is "no list to
|
||||
* judge" — before the grant, and on a read that failed.
|
||||
*/
|
||||
private val calendars: StateFlow<List<CalendarSource>?> = hasPermission
|
||||
.flatMapLatest { granted ->
|
||||
if (granted != true) {
|
||||
flowOf(null)
|
||||
} else {
|
||||
val list: Flow<List<CalendarSource>?> = repository.calendars()
|
||||
list.catch { emit(null) }.flowOn(io)
|
||||
}
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
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
|
||||
@@ -81,17 +105,90 @@ class OnboardingViewModel @Inject constructor(
|
||||
* 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 backupApplies: Flow<Boolean?> = calendars.map { list ->
|
||||
if (list.isNullOrEmpty()) null else list.backupApplies()
|
||||
}
|
||||
|
||||
/** How the step's own create attempt ended, while it is still saying so (#287). */
|
||||
private val _creation = MutableStateFlow<CalendarCreation?>(null)
|
||||
val creation: StateFlow<CalendarCreation?> = _creation.asStateFlow()
|
||||
|
||||
/**
|
||||
* Whether the step made a calendar here. Latched for the session and never
|
||||
* cleared: the new calendar answers [calendarsApplies] with false, which
|
||||
* would drop the step out of the plan the moment it is used — first from
|
||||
* under the message that it worked, and then from the step count, which
|
||||
* would fall by one just as the user pressed Continue.
|
||||
*/
|
||||
private val calendarCreatedHere = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* Whether the calendar step applies: the device has nothing to show and so
|
||||
* nothing to put events in. Unlike [backupApplies] an empty list is the
|
||||
* condition itself, not a failed read — the two look identical here and both
|
||||
* want the step, so this only resolves once a calendar the app can actually
|
||||
* show has arrived. Calendars that exist but are all switched off leave it
|
||||
* unresolved too: the step in front (#287) offers to switch them on, and
|
||||
* declining that lands here, on the same offer a device with none gets.
|
||||
*/
|
||||
private val calendarsApplies: Flow<Boolean?> = combine(
|
||||
calendars.map { list -> if (list?.any { it.isVisibleInSystem } == true) false else null },
|
||||
calendarCreatedHere,
|
||||
) { applies, latched -> if (latched) null else applies }
|
||||
|
||||
/**
|
||||
* Set when the step starts a write of its own, and never cleared: from that
|
||||
* moment the step holds its place in the plan until it is answered. Set
|
||||
* *before* the write rather than after it, because a successful one answers
|
||||
* [hiddenApplies] with false — the step would drop out between the provider
|
||||
* taking the change and the latch seeing it.
|
||||
*/
|
||||
private val hiddenTouched = MutableStateFlow(false)
|
||||
|
||||
/** A visibility write the provider did not take, while the step says so (#287). */
|
||||
private val _hiddenWriteFailed = MutableStateFlow(false)
|
||||
val hiddenWriteFailed: StateFlow<Boolean> = _hiddenWriteFailed.asStateFlow()
|
||||
|
||||
/**
|
||||
* Whether the device is showing nothing at all right now: calendars exist,
|
||||
* none is visible, and at least one of them has a switch the step can flip
|
||||
* (#287). Null only while there is no list to judge — before the grant, and
|
||||
* on a read that failed — which keeps the step in the plan until the answer
|
||||
* arrives, the same shrink-only contract [backupApplies] keeps.
|
||||
*
|
||||
* An empty list answers false rather than null, unlike [calendarsApplies]:
|
||||
* a phone with no calendars at all is the next step's business, and this one
|
||||
* would be listing calendars it cannot see. [hiddenTouched] holds it at null
|
||||
* from the step's first write onwards.
|
||||
*/
|
||||
private val hiddenApplies: Flow<Boolean?> = combine(
|
||||
calendars.map { list -> list?.allHidden() },
|
||||
hiddenTouched,
|
||||
) { hidden, touched -> if (touched) null else hidden }
|
||||
|
||||
/** Both calendar steps' inputs: what each applies to, and whether it is answered. */
|
||||
private val calendarsFlags: Flow<CalendarsFlags> = combine(
|
||||
calendarsApplies,
|
||||
prefs.onboardingCalendarsDone,
|
||||
hiddenApplies,
|
||||
prefs.onboardingHiddenCalendarsDone,
|
||||
::CalendarsFlags,
|
||||
)
|
||||
|
||||
/**
|
||||
* The calendars the hidden-calendars step lists, so the user can judge which
|
||||
* to switch on rather than take the app's word that there are some. Live: a
|
||||
* row follows its own switch, and the ones without one stay listed anyway —
|
||||
* being told a calendar exists is half the point.
|
||||
*/
|
||||
val hiddenCalendars: StateFlow<List<CalendarSource>> = calendars
|
||||
.map { it.orEmpty().orderedForManager() }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
private val flags: Flow<OnboardingFlags> = combine(
|
||||
prefs.reminderOnboardingDone,
|
||||
prefs.onboardingWizardArmed,
|
||||
@@ -110,7 +207,13 @@ class OnboardingViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
val plan: StateFlow<OnboardingPlan?> =
|
||||
combine(hasPermission, flags, closingFlags, backupApplies) { granted, stored, closing, backup ->
|
||||
combine(
|
||||
hasPermission,
|
||||
flags,
|
||||
closingFlags,
|
||||
backupApplies,
|
||||
calendarsFlags,
|
||||
) { granted, stored, closing, backup, calendars ->
|
||||
granted?.let {
|
||||
onboardingPlan(
|
||||
hasPermission = it,
|
||||
@@ -120,6 +223,10 @@ class OnboardingViewModel @Inject constructor(
|
||||
viewDone = stored.viewDone,
|
||||
monthStyleDone = stored.monthStyleDone,
|
||||
backupApplies = backup,
|
||||
hiddenDone = calendars.hiddenDone,
|
||||
hiddenApplies = calendars.hiddenApplies,
|
||||
calendarsDone = calendars.done,
|
||||
calendarsApplies = calendars.applies,
|
||||
visibilityArmed = closing.visibilityArmed,
|
||||
visibilityDone = closing.visibilityDone,
|
||||
doneShown = closing.doneShown,
|
||||
@@ -186,6 +293,8 @@ class OnboardingViewModel @Inject constructor(
|
||||
fun goBack() {
|
||||
viewModelScope.launch {
|
||||
when (plan.value?.previous) {
|
||||
OnboardingStep.HiddenCalendars -> prefs.setOnboardingHiddenCalendarsDone(false)
|
||||
OnboardingStep.Calendars -> prefs.setOnboardingCalendarsDone(false)
|
||||
OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false)
|
||||
OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false)
|
||||
OnboardingStep.View -> prefs.setOnboardingViewDone(false)
|
||||
@@ -202,6 +311,53 @@ class OnboardingViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setOnboardingBackupDone() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the device-only calendar the step's editor just described. The step
|
||||
* stays up on [creation] either way — to confirm the calendar and say where
|
||||
* to make more, or to say the provider refused it. Answering the step for
|
||||
* them would leave them where the step exists to stop them being: past it,
|
||||
* with nowhere to put an event.
|
||||
*/
|
||||
fun createLocalCalendar(displayName: String, color: Int, description: String?) {
|
||||
viewModelScope.launch {
|
||||
val name = displayName.trim()
|
||||
_creation.value = runCatching {
|
||||
repository.createLocalCalendar(
|
||||
displayName = name,
|
||||
color = color,
|
||||
description = description,
|
||||
)
|
||||
}.fold(
|
||||
onSuccess = {
|
||||
calendarCreatedHere.value = true
|
||||
CalendarCreation.Created(name)
|
||||
},
|
||||
onFailure = { CalendarCreation.Failed },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the calendar step once its confirmation has been read. */
|
||||
fun finishCalendars() {
|
||||
viewModelScope.launch {
|
||||
prefs.setOnboardingCalendarsDone()
|
||||
_creation.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the failed attempt so the step offers itself again. */
|
||||
fun clearCalendarCreation() {
|
||||
_creation.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the calendar step without making one. An empty app is a legitimate
|
||||
* choice, and so is going off to set up an account and coming back.
|
||||
*/
|
||||
fun skipCalendars() {
|
||||
viewModelScope.launch { prefs.setOnboardingCalendarsDone() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn automatic backup on, writing to the folder the user just picked
|
||||
* (taking a durable write grant so background runs can keep writing), and
|
||||
@@ -246,6 +402,52 @@ class OnboardingViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setOnboardingMonthStyleDone() }
|
||||
}
|
||||
|
||||
/** Switch one listed calendar on or off, from the hidden-calendars step (#287). */
|
||||
fun setHiddenCalendarVisible(id: Long, visible: Boolean) = visibilityWrite {
|
||||
repository.setCalendarsVisible(listOf(id), visible)
|
||||
calendarList().any { it.id == id && it.isVisibleInSystem == visible }
|
||||
}
|
||||
|
||||
/** Switch every hidden calendar back on — the step's one-tap way out (#287). */
|
||||
fun switchAllCalendarsOn() = visibilityWrite {
|
||||
val hidden = calendarList().filter { it.hasVisibilitySwitch && !it.isVisibleInSystem }
|
||||
// Nothing to write is not a write that landed: a list that came back
|
||||
// empty (a failed read) would otherwise report success and leave the
|
||||
// step sitting there with no rows and no explanation.
|
||||
if (hidden.isEmpty()) return@visibilityWrite false
|
||||
repository.setCalendarsVisible(hidden.map { it.id }, true)
|
||||
calendarList().any { it.isVisibleInSystem }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one of the step's visibility writes, whose lambda answers whether it
|
||||
* took. Checked rather than assumed: with read-only calendar access the
|
||||
* choice is kept app-side (#75) and nothing the grid shows would change.
|
||||
*/
|
||||
private fun visibilityWrite(landed: suspend () -> Boolean) {
|
||||
viewModelScope.launch {
|
||||
hiddenTouched.value = true
|
||||
_hiddenWriteFailed.value = !runCatching { landed() }.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun calendarList(): List<CalendarSource> = repository.calendars().first()
|
||||
|
||||
/** Close the hidden-calendars step, switched on or left as they were (#287). */
|
||||
fun finishHiddenCalendars() {
|
||||
viewModelScope.launch {
|
||||
prefs.setOnboardingHiddenCalendarsDone()
|
||||
// The error belongs to the attempt, not to the step: stepping back
|
||||
// into it later must not find a refusal nothing has asked for yet.
|
||||
_hiddenWriteFailed.value = false
|
||||
val list = calendarList()
|
||||
// Switching them all back on answers the one-time notice too (#75) —
|
||||
// it would otherwise announce, at the end of this same flow, that the
|
||||
// device is hiding calendars it no longer hides.
|
||||
if (list.isNotEmpty() && list.all { it.isVisibleInSystem }) retireVisibilityNotice()
|
||||
}
|
||||
}
|
||||
|
||||
/** Acknowledge the calendar-visibility notice (#75). */
|
||||
fun finishVisibility() {
|
||||
viewModelScope.launch {
|
||||
@@ -295,6 +497,20 @@ private data class OnboardingFlags(
|
||||
val monthStyleDone: Boolean,
|
||||
)
|
||||
|
||||
/** How the calendar step's create attempt ended (#287). */
|
||||
sealed interface CalendarCreation {
|
||||
data class Created(val name: String) : CalendarCreation
|
||||
data object Failed : CalendarCreation
|
||||
}
|
||||
|
||||
/** Both calendar steps' inputs (#287). */
|
||||
private data class CalendarsFlags(
|
||||
val applies: Boolean?,
|
||||
val done: Boolean,
|
||||
val hiddenApplies: Boolean?,
|
||||
val hiddenDone: Boolean,
|
||||
)
|
||||
|
||||
/** The tail of the flow: the visibility notice and the closing screen. */
|
||||
private data class ClosingFlags(
|
||||
val visibilityArmed: Boolean,
|
||||
@@ -309,6 +525,15 @@ data class ViewChoice(
|
||||
val weekStart: WeekStartPref,
|
||||
)
|
||||
|
||||
/**
|
||||
* True when this device would show nothing and the step could change that: no
|
||||
* calendar is visible, and at least one of them has a switch worth flipping —
|
||||
* a non-syncing calendar holds no events on the device, so switching it on
|
||||
* leaves the grid exactly as empty (#287).
|
||||
*/
|
||||
private fun List<CalendarSource>.allHidden(): Boolean =
|
||||
any { it.hasVisibilitySwitch } && none { it.isVisibleInSystem }
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -18,11 +18,11 @@ 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.LocalMultiDayCount
|
||||
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.common.label
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
||||
@@ -67,14 +67,19 @@ internal fun ViewStep(
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
CalendarView.Week -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT)
|
||||
CalendarView.Day -> DayViewPreview(height = PREVIEW_HEIGHT)
|
||||
CalendarView.MultiDay -> WeekViewPreview(
|
||||
weekStart = weekStart,
|
||||
height = PREVIEW_HEIGHT,
|
||||
dayCount = LocalMultiDayCount.current,
|
||||
)
|
||||
CalendarView.Day -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT, dayCount = 1)
|
||||
CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT)
|
||||
}
|
||||
}
|
||||
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
|
||||
val isSelected = view == choice.defaultView
|
||||
GroupedRow(
|
||||
title = stringResource(view.labelRes),
|
||||
title = view.label(),
|
||||
position = positionOf(index, IMPLEMENTED_VIEWS.size),
|
||||
selected = isSelected,
|
||||
leading = {
|
||||
|
||||
@@ -126,6 +126,7 @@ fun SearchScreen(
|
||||
onBack: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
visible: Boolean = true,
|
||||
viewModel: SearchViewModel = hiltViewModel(),
|
||||
) {
|
||||
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||
@@ -184,7 +185,7 @@ fun SearchScreen(
|
||||
BackHandler(enabled = inSelection) { viewModel.clearSelection() }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection),
|
||||
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection, visible = visible),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
topBar = {
|
||||
SearchTopBar(
|
||||
@@ -509,6 +510,9 @@ private fun SearchResultRow(
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val event = hit.event
|
||||
// Spans are empty for a blank title — the query never matches the
|
||||
// placeholder — so marking the substitute is safe.
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
// On a picked row the headline is already recoloured for the secondary
|
||||
@@ -521,7 +525,7 @@ private fun SearchResultRow(
|
||||
// Faded like a past event anywhere else in the app — search reaches back
|
||||
// through the whole history.
|
||||
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = declinedTitle(marked(event.title, hit.titleSpans, highlight), event.isDeclined),
|
||||
title = declinedTitle(marked(title, hit.titleSpans, highlight), event.isDeclined),
|
||||
summary = searchSummary(hit, highlight),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
|
||||
@@ -76,6 +76,7 @@ internal fun AppearanceScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean = true,
|
||||
) {
|
||||
var showTheme by remember { mutableStateOf(false) }
|
||||
var showBrandFont by remember { mutableStateOf(false) }
|
||||
@@ -96,6 +97,7 @@ internal fun AppearanceScreen(
|
||||
title = stringResource(R.string.settings_section_appearance),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
// Theme & colour
|
||||
GroupedRow(
|
||||
|
||||
@@ -41,6 +41,7 @@ internal fun EventFormScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean = true,
|
||||
) {
|
||||
var showDefaultDuration by remember { mutableStateOf(false) }
|
||||
// The calendar whose duration-override picker is open, if any.
|
||||
@@ -53,6 +54,7 @@ internal fun EventFormScreen(
|
||||
title = stringResource(R.string.settings_section_event_form),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
SettingsHint(stringResource(R.string.settings_form_fields_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -143,7 +145,7 @@ internal fun EventFormScreen(
|
||||
durationCalendars.forEachIndexed { index, calendar ->
|
||||
val override = state.perCalendarEventDuration[calendar.id]
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
summary = override?.let { durationLabel(it) }
|
||||
?: stringResource(
|
||||
R.string.settings_calendar_duration_inherits,
|
||||
|
||||
@@ -63,6 +63,7 @@ internal fun NotificationsScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
onOpenSpecialDates: () -> Unit,
|
||||
visible: Boolean = true,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
@@ -92,6 +93,7 @@ internal fun NotificationsScreen(
|
||||
title = stringResource(R.string.settings_section_notifications),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_reminders),
|
||||
@@ -181,7 +183,7 @@ internal fun NotificationsScreen(
|
||||
// own section — link there instead.
|
||||
if (calendar.id in state.managedCalendarIds) {
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
||||
position = Position.Alone,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
@@ -198,7 +200,7 @@ internal fun NotificationsScreen(
|
||||
}
|
||||
val expanded = calendar.id in expandedCalendars
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
position = if (expanded) Position.Top else Position.Alone,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
|
||||
@@ -2,9 +2,7 @@ package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -66,6 +64,7 @@ import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import de.jeanlucmakiola.floret.crash.openIssueTracker
|
||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBackExit
|
||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||
|
||||
/** The settings sub-screens reached from the hub's category rows. */
|
||||
@@ -85,11 +84,14 @@ fun SettingsScreen(
|
||||
onManageCalendars: () -> Unit,
|
||||
onOpenBackup: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
visible: Boolean = true,
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
// Leaves from the predictive-back preview rather than sliding off at full size.
|
||||
val overlayExit = predictiveBackExit()
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -101,61 +103,63 @@ fun SettingsScreen(
|
||||
onOpenSection = { section = it },
|
||||
onManageCalendars = onManageCalendars,
|
||||
onOpenBackup = onOpenBackup,
|
||||
visible = visible,
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.Appearance,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Appearance)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.Views,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Views)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.EventForm,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.EventForm)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.Notifications,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
NotificationsScreen(
|
||||
state = state,
|
||||
viewModel = viewModel,
|
||||
onBack = { section = null },
|
||||
onOpenSpecialDates = { section = SettingsSection.SpecialDates },
|
||||
visible = section == SettingsSection.Notifications,
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.SpecialDates,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
SpecialDatesScreen(viewModel = viewModel, onBack = { section = null })
|
||||
SpecialDatesScreen(viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.SpecialDates)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.Widgets,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Widgets)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.Licences,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
LicencesScreen(onBack = { section = null })
|
||||
LicencesScreen(onBack = { section = null }, visible = section == SettingsSection.Licences)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,8 +174,14 @@ private fun SettingsHub(
|
||||
onOpenSection: (SettingsSection) -> Unit,
|
||||
onManageCalendars: () -> Unit,
|
||||
onOpenBackup: () -> Unit,
|
||||
visible: Boolean,
|
||||
) {
|
||||
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack, predictiveBack = true) {
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_title),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
// Card and support row are one grouped block, so the call to action
|
||||
// continues the container instead of sitting inside it as a button.
|
||||
Box(Modifier.padding(horizontal = GroupedListInset)) { AboutCard() }
|
||||
|
||||
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
@@ -34,6 +35,8 @@ data class SettingsUiState(
|
||||
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
||||
/** Whether the week/day timeline seats each hour in its own cell (v2.11). */
|
||||
val showHourGrid: Boolean = false,
|
||||
/** Days the multi-day view shows side by side (#193). */
|
||||
val multiDayCount: Int = SettingsPrefs.DEFAULT_MULTI_DAY_COUNT,
|
||||
/** How the Agenda screen treats events that already ended today. */
|
||||
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
|
||||
/** Whether the month/week grids fade events that have already finished. */
|
||||
@@ -62,6 +65,7 @@ data class SettingsUiState(
|
||||
val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged,
|
||||
/** How tall an hour is drawn in the week and day timelines (#56). */
|
||||
val timelineScale: TimelineScale = TimelineScale.Regular,
|
||||
val timelineHours: TimelineHours = TimelineHours(),
|
||||
/** Order of the views in the navigation drawer (#24); every view is always listed. */
|
||||
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||
|
||||
@@ -36,6 +36,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
||||
@@ -148,11 +149,12 @@ class SettingsViewModel @Inject constructor(
|
||||
combine(
|
||||
prefs.todayButtonInToolbar,
|
||||
prefs.dragToReschedule,
|
||||
::Pair,
|
||||
prefs.multiDayCount,
|
||||
::Triple,
|
||||
),
|
||||
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove) ->
|
||||
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove, multiDay) ->
|
||||
DisplayToggles(
|
||||
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove,
|
||||
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove, multiDay,
|
||||
)
|
||||
},
|
||||
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
||||
@@ -164,6 +166,7 @@ class SettingsViewModel @Inject constructor(
|
||||
softenColors = toggles.softenColors,
|
||||
todayButtonInToolbar = toggles.todayButtonInToolbar,
|
||||
dragToReschedule = toggles.dragToReschedule,
|
||||
multiDayCount = toggles.multiDayCount,
|
||||
)
|
||||
},
|
||||
combine(
|
||||
@@ -178,9 +181,11 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.drawerViewOrder,
|
||||
prefs.monthViewStyle,
|
||||
prefs.widgetSize,
|
||||
prefs.timelineScale,
|
||||
) { quickSwitch, drawer, monthStyle, widgetSize, timelineScale ->
|
||||
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize, timelineScale)
|
||||
combine(prefs.timelineScale, prefs.timelineHours, ::Pair),
|
||||
) { quickSwitch, drawer, monthStyle, widgetSize, (timelineScale, timelineHours) ->
|
||||
ViewCustomization(
|
||||
quickSwitch, drawer, monthStyle, widgetSize, timelineScale, timelineHours,
|
||||
)
|
||||
},
|
||||
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
||||
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
||||
@@ -197,6 +202,7 @@ class SettingsViewModel @Inject constructor(
|
||||
softenColors = views.softenColors,
|
||||
todayButtonInToolbar = views.todayButtonInToolbar,
|
||||
dragToReschedule = views.dragToReschedule,
|
||||
multiDayCount = views.multiDayCount,
|
||||
agendaShowRangeBar = misc.showRangeBar,
|
||||
autofocusEventTitle = misc.autofocusEventTitle,
|
||||
pastEventDisplay = misc.pastEventDisplay,
|
||||
@@ -206,6 +212,7 @@ class SettingsViewModel @Inject constructor(
|
||||
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
||||
widgetSize = misc.viewCustomization.widgetSize,
|
||||
timelineScale = misc.viewCustomization.timelineScale,
|
||||
timelineHours = misc.viewCustomization.timelineHours,
|
||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||
defaultReminderMinutes = defaults.defaultReminder,
|
||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||
@@ -298,6 +305,7 @@ class SettingsViewModel @Inject constructor(
|
||||
val softenColors: Boolean,
|
||||
val todayButtonInToolbar: Boolean,
|
||||
val dragToReschedule: Boolean,
|
||||
val multiDayCount: Int,
|
||||
)
|
||||
|
||||
private data class DisplayToggles(
|
||||
@@ -307,6 +315,7 @@ class SettingsViewModel @Inject constructor(
|
||||
val softenColors: Boolean,
|
||||
val todayButtonInToolbar: Boolean,
|
||||
val dragToReschedule: Boolean,
|
||||
val multiDayCount: Int,
|
||||
)
|
||||
|
||||
private data class MiscSettings(
|
||||
@@ -323,6 +332,7 @@ class SettingsViewModel @Inject constructor(
|
||||
val monthViewStyle: MonthViewStyle,
|
||||
val widgetSize: WidgetSize,
|
||||
val timelineScale: TimelineScale,
|
||||
val timelineHours: TimelineHours,
|
||||
)
|
||||
|
||||
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
||||
@@ -532,6 +542,10 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setTimeFormat(pref) }
|
||||
}
|
||||
|
||||
fun setMultiDayCount(count: Int) {
|
||||
viewModelScope.launch { prefs.setMultiDayCount(count) }
|
||||
}
|
||||
|
||||
fun setShowHourGrid(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setShowHourGrid(enabled) }
|
||||
}
|
||||
@@ -617,6 +631,10 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setTimelineScale(scale) }
|
||||
}
|
||||
|
||||
fun setTimelineHours(hours: TimelineHours) {
|
||||
viewModelScope.launch { prefs.setTimelineHours(hours) }
|
||||
}
|
||||
|
||||
fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
internal fun SpecialDatesScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean = true,
|
||||
) {
|
||||
val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
|
||||
val settings by viewModel.state.collectAsStateWithLifecycle()
|
||||
@@ -76,6 +77,7 @@ internal fun SpecialDatesScreen(
|
||||
title = stringResource(R.string.settings_section_special_dates),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
// Paused banner: the permission was revoked after enabling.
|
||||
if (state.enabled && state.stalledPermission) {
|
||||
|
||||
@@ -21,10 +21,12 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
@@ -32,11 +34,15 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
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.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.descriptionRes
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||
import de.jeanlucmakiola.calendula.ui.common.label
|
||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
@@ -62,6 +68,7 @@ internal fun ViewsScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean = true,
|
||||
) {
|
||||
var showMonthStyle by remember { mutableStateOf(false) }
|
||||
var showDefaultView by remember { mutableStateOf(false) }
|
||||
@@ -70,11 +77,16 @@ internal fun ViewsScreen(
|
||||
var showPastEvents by remember { mutableStateOf(false) }
|
||||
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||
var showTimelineScale by remember { mutableStateOf(false) }
|
||||
var showMultiDayCount by remember { mutableStateOf(false) }
|
||||
var showHoursStart by remember { mutableStateOf(false) }
|
||||
var showHoursEnd by remember { mutableStateOf(false) }
|
||||
val hours = state.timelineHours
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_views),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
val config = state.quickSwitchConfig
|
||||
|
||||
@@ -82,7 +94,7 @@ internal fun ViewsScreen(
|
||||
SectionHeader(stringResource(R.string.settings_views_all_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_view),
|
||||
summary = stringResource(state.defaultView.labelRes),
|
||||
summary = state.defaultView.label(),
|
||||
position = Position.Top,
|
||||
onClick = { showDefaultView = true },
|
||||
)
|
||||
@@ -159,11 +171,56 @@ internal fun ViewsScreen(
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SectionHeader(stringResource(R.string.settings_week_day_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_timeline_scale),
|
||||
summary = stringResource(state.timelineScale.labelRes),
|
||||
title = stringResource(R.string.settings_multi_day_count),
|
||||
summary = multiDayLabel(state.multiDayCount),
|
||||
position = Position.Top,
|
||||
onClick = { showTimelineScale = true },
|
||||
onClick = { showMultiDayCount = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_timeline_scale),
|
||||
summary = if (hours.enabled) {
|
||||
stringResource(R.string.settings_timeline_scale_by_visible_hours)
|
||||
} else {
|
||||
stringResource(state.timelineScale.labelRes)
|
||||
},
|
||||
position = Position.Middle,
|
||||
dimmed = hours.enabled,
|
||||
onClick = if (hours.enabled) null else ({ showTimelineScale = true }),
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_visible_hours),
|
||||
summary = if (hours.enabled) {
|
||||
stringResource(
|
||||
R.string.settings_visible_hours_on_summary,
|
||||
hourLabel(hours.startHour),
|
||||
hourLabel(hours.endHour),
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.settings_visible_hours_summary)
|
||||
},
|
||||
position = Position.Middle,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = hours.enabled,
|
||||
onCheckedChange = { viewModel.setTimelineHours(hours.copy(enabled = it)) },
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setTimelineHours(hours.copy(enabled = !hours.enabled)) },
|
||||
)
|
||||
if (hours.enabled) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_visible_hours_start),
|
||||
summary = hourLabel(hours.startHour),
|
||||
position = Position.Middle,
|
||||
onClick = { showHoursStart = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_visible_hours_end),
|
||||
summary = hourLabel(hours.endHour),
|
||||
position = Position.Middle,
|
||||
onClick = { showHoursEnd = true },
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_hour_lines),
|
||||
summary = stringResource(R.string.settings_hour_lines_summary),
|
||||
@@ -275,7 +332,7 @@ internal fun ViewsScreen(
|
||||
predictiveBack = true,
|
||||
options = IMPLEMENTED_VIEWS,
|
||||
selected = state.defaultView,
|
||||
label = { stringResource(it.labelRes) },
|
||||
label = { it.label() },
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = it.icon,
|
||||
@@ -337,6 +394,18 @@ internal fun ViewsScreen(
|
||||
onDismiss = { showPastEvents = false },
|
||||
)
|
||||
}
|
||||
if (showMultiDayCount) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_multi_day_count),
|
||||
header = { PickerDescription(stringResource(R.string.settings_multi_day_count_hint)) },
|
||||
predictiveBack = true,
|
||||
options = SettingsPrefs.MULTI_DAY_COUNTS.toList(),
|
||||
selected = state.multiDayCount,
|
||||
label = { multiDayLabel(it) },
|
||||
onSelect = viewModel::setMultiDayCount,
|
||||
onDismiss = { showMultiDayCount = false },
|
||||
)
|
||||
}
|
||||
if (showTimelineScale) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_timeline_scale),
|
||||
@@ -356,6 +425,30 @@ internal fun ViewsScreen(
|
||||
onDismiss = { showTimelineScale = false },
|
||||
)
|
||||
}
|
||||
if (showHoursStart) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_visible_hours_start),
|
||||
header = { PickerDescription(stringResource(R.string.settings_visible_hours_start_hint)) },
|
||||
predictiveBack = true,
|
||||
options = TimelineHours.startOptions(hours.endHour),
|
||||
selected = hours.startHour,
|
||||
label = { hourLabel(it) },
|
||||
onSelect = { viewModel.setTimelineHours(hours.copy(startHour = it)) },
|
||||
onDismiss = { showHoursStart = false },
|
||||
)
|
||||
}
|
||||
if (showHoursEnd) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_visible_hours_end),
|
||||
header = { PickerDescription(stringResource(R.string.settings_visible_hours_end_hint)) },
|
||||
predictiveBack = true,
|
||||
options = TimelineHours.endOptions(hours.startHour),
|
||||
selected = hours.endHour,
|
||||
label = { hourLabel(it) },
|
||||
onSelect = { viewModel.setTimelineHours(hours.copy(endHour = it)) },
|
||||
onDismiss = { showHoursEnd = false },
|
||||
)
|
||||
}
|
||||
if (showAgendaScreenRange) {
|
||||
AgendaRangePicker(
|
||||
title = stringResource(R.string.settings_agenda_range),
|
||||
@@ -384,7 +477,7 @@ private fun ViewRow(
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = stringResource(view.labelRes),
|
||||
title = view.label(),
|
||||
position = position,
|
||||
dimmed = dimmed,
|
||||
minHeight = ReorderableRowHeight,
|
||||
@@ -449,3 +542,12 @@ internal fun pastEventDisplayLabelRes(mode: PastEventDisplay): Int = when (mode)
|
||||
PastEventDisplay.DIM -> R.string.settings_past_events_dim
|
||||
PastEventDisplay.HIDE -> R.string.settings_past_events_hide
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun multiDayLabel(count: Int): String =
|
||||
pluralStringResource(R.plurals.view_multi_day, count, count)
|
||||
|
||||
/** A whole hour in the app's clock convention; 24 reads as the end of the day. */
|
||||
@Composable
|
||||
private fun hourLabel(hour: Int): String =
|
||||
formatMinuteOfDay(hour * 60, LocalUse24HourFormat.current, currentLocale())
|
||||
|
||||
@@ -42,6 +42,7 @@ internal fun WidgetsScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean = true,
|
||||
) {
|
||||
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
||||
var showWidgetSize by remember { mutableStateOf(false) }
|
||||
@@ -50,6 +51,7 @@ internal fun WidgetsScreen(
|
||||
title = stringResource(R.string.settings_section_widgets),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
SettingsHint(stringResource(R.string.settings_widgets_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
package de.jeanlucmakiola.calendula.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Default Material 3 Expressive typography. Custom font + tuned scale will
|
||||
* land in a later UI-design iteration; the defaults are intentional for V1
|
||||
* scaffolding to keep the foundation lean.
|
||||
* Tracking the two label roles the calendar grids are set in. Material gives
|
||||
* both 0.5sp, tuned for isolated UI labels with room around them; a month chip
|
||||
* is a text box some 37dp wide, where 0.5sp on an 11sp glyph spends most of a
|
||||
* character on spacing alone. 0.1sp is what Material itself sets labelLarge to,
|
||||
* so the label family stays coherent (#190).
|
||||
*/
|
||||
val CalendulaTypography = Typography()
|
||||
private val LabelTracking = 0.1.sp
|
||||
|
||||
/**
|
||||
* Material 3 Expressive typography with the label roles' tracking tightened.
|
||||
* Everything else is the default scale.
|
||||
*/
|
||||
val CalendulaTypography: Typography = Typography().let { base ->
|
||||
base.copy(
|
||||
labelMedium = base.labelMedium.copy(letterSpacing = LabelTracking),
|
||||
labelSmall = base.labelSmall.copy(letterSpacing = LabelTracking),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.plus
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** A day column's place across the timeline, in pixels from its start edge. */
|
||||
@Immutable
|
||||
data class ColumnSlot(val left: Float, val width: Float) {
|
||||
val right: Float get() = left + width
|
||||
}
|
||||
|
||||
/**
|
||||
* [date]'s column on a page showing this span across [width] pixels, with [gap]
|
||||
* between columns. A date off the page carries on at the same pitch, off screen
|
||||
* on its side — where a view switch slides it in from, or out to.
|
||||
*/
|
||||
fun DaySpan.slot(date: LocalDate, width: Float, gap: Float): ColumnSlot {
|
||||
val pitch = (width + gap) / count
|
||||
return ColumnSlot(left = start.daysUntil(date) * pitch, width = pitch - gap)
|
||||
}
|
||||
|
||||
/** The last day of the span. */
|
||||
val DaySpan.end: LocalDate get() = start.plus(count - 1, DateTimeUnit.DAY)
|
||||
|
||||
/**
|
||||
* The timeline's columns: one page's, or — while switching between the day, the
|
||||
* multi-day and the week view (#184) — every day of both pages, each column
|
||||
* between where it sits in the one and where it sits in the other.
|
||||
*
|
||||
* @param progress how far from [from] to [to], read at layout time only.
|
||||
*/
|
||||
@Stable
|
||||
class ColumnGeometry(val from: DaySpan, val to: DaySpan, val progress: () -> Float) {
|
||||
|
||||
/** Every day either page shows, in order: the two overlap, so they run on unbroken. */
|
||||
val dates: List<LocalDate> = run {
|
||||
val first = minOf(from.start, to.start)
|
||||
val last = maxOf(from.end, to.end)
|
||||
(0..first.daysUntil(last)).map { first.plus(it, DateTimeUnit.DAY) }
|
||||
}
|
||||
|
||||
fun slot(date: LocalDate, width: Float, gap: Float): ColumnSlot {
|
||||
val a = from.slot(date, width, gap)
|
||||
if (from == to) return a
|
||||
val b = to.slot(date, width, gap)
|
||||
val p = progress()
|
||||
return ColumnSlot(lerp(a.left, b.left, p), lerp(a.width, b.width, p))
|
||||
}
|
||||
|
||||
/** The span of columns [range] covers, between its extent on each page. */
|
||||
fun extent(fromRange: ClosedRange<LocalDate>, toRange: ClosedRange<LocalDate>, width: Float, gap: Float): ColumnSlot {
|
||||
val a = from.extent(fromRange, width, gap)
|
||||
if (from == to && fromRange == toRange) return a
|
||||
val b = to.extent(toRange, width, gap)
|
||||
val p = progress()
|
||||
val left = lerp(a.left, b.left, p)
|
||||
return ColumnSlot(left, lerp(a.right, b.right, p) - left)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** One page's columns, standing still. */
|
||||
fun of(span: DaySpan): ColumnGeometry = ColumnGeometry(span, span) { 0f }
|
||||
}
|
||||
}
|
||||
|
||||
private fun DaySpan.extent(range: ClosedRange<LocalDate>, width: Float, gap: Float): ColumnSlot {
|
||||
val left = slot(range.start, width, gap).left
|
||||
return ColumnSlot(left, slot(range.endInclusive, width, gap).right - left)
|
||||
}
|
||||
|
||||
internal fun lerp(a: Float, b: Float, p: Float): Float = a + (b - a) * p
|
||||
|
||||
/**
|
||||
* Lays [content] out once per day of [geometry], each across its column. The
|
||||
* columns are placed in the layout pass, so a view switch moving them every
|
||||
* frame re-lays them out without recomposing them. Children fill the height
|
||||
* the layout is given, or wrap it when it has none.
|
||||
*/
|
||||
@Composable
|
||||
fun TimelineColumns(
|
||||
geometry: ColumnGeometry,
|
||||
gap: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable (LocalDate) -> Unit,
|
||||
) {
|
||||
val dates = geometry.dates
|
||||
Layout(
|
||||
content = {
|
||||
dates.forEach { date ->
|
||||
key(date) { Box(propagateMinConstraints = true) { content(date) } }
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
) { measurables, constraints ->
|
||||
val width = constraints.maxWidth
|
||||
val gapPx = gap.toPx()
|
||||
val slots = dates.map { geometry.slot(it, width.toFloat(), gapPx) }
|
||||
val placeables = measurables.mapIndexed { i, measurable ->
|
||||
val left = slots[i].left.roundToInt()
|
||||
val columnWidth = (slots[i].right.roundToInt() - left).coerceAtLeast(0)
|
||||
measurable.measure(constraints.copy(minWidth = columnWidth, maxWidth = columnWidth))
|
||||
}
|
||||
val height = placeables.maxOfOrNull { it.height }?.coerceAtLeast(constraints.minHeight)
|
||||
?: constraints.minHeight
|
||||
layout(width, height) {
|
||||
placeables.forEachIndexed { i, placeable ->
|
||||
placeable.placeRelative(slots[i].left.roundToInt(), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An all-day bar as each page lays it out: the days it covers there, clipped to
|
||||
* the page, and its lane. A bar only one page has keeps its days on the other,
|
||||
* so it slides out with them rather than vanishing.
|
||||
*/
|
||||
@Immutable
|
||||
data class PlacedBar(
|
||||
val event: EventInstance,
|
||||
/** The day the bar is drawn from on the page it is tagged on, for the view morph. */
|
||||
val keyDate: LocalDate,
|
||||
val fromRange: ClosedRange<LocalDate>,
|
||||
val toRange: ClosedRange<LocalDate>,
|
||||
val fromLane: Int,
|
||||
val toLane: Int,
|
||||
)
|
||||
|
||||
/** [state]'s all-day bars, standing still. */
|
||||
internal fun staticBars(state: WeekUiState.Success): List<PlacedBar> =
|
||||
state.allDaySpans.map { span ->
|
||||
val range = state.days[span.startCol]..state.days[span.endCol]
|
||||
PlacedBar(span.event, range.start, range, range, span.lane, span.lane)
|
||||
}
|
||||
|
||||
/**
|
||||
* The all-day bars of both pages of a view switch, each once: matched by
|
||||
* occurrence, placed on each page as that page lays it out.
|
||||
*/
|
||||
internal fun morphBars(from: WeekUiState.Success?, to: WeekUiState.Success?): List<PlacedBar> {
|
||||
val fromBars = from?.let(::staticBars).orEmpty().associateBy { it.event.occurrence() }
|
||||
val toBars = to?.let(::staticBars).orEmpty().associateBy { it.event.occurrence() }
|
||||
return (fromBars.keys + toBars.keys).map { occurrence ->
|
||||
val a = fromBars[occurrence]
|
||||
val b = toBars[occurrence]
|
||||
PlacedBar(
|
||||
event = (a ?: b)!!.event,
|
||||
keyDate = (a ?: b)!!.keyDate,
|
||||
fromRange = (a ?: b)!!.fromRange,
|
||||
toRange = (b ?: a)!!.toRange,
|
||||
fromLane = (a ?: b)!!.fromLane,
|
||||
toLane = (b ?: a)!!.toLane,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun EventInstance.occurrence(): Pair<Long, Long> = eventId to start.toEpochMilliseconds()
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.runtime.Stable
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* A switch between two timeline views in progress (#184): the page on screen
|
||||
* when it started, the page it ends on, and how far between the two it is.
|
||||
*/
|
||||
@Stable
|
||||
internal class TimelineMorph(
|
||||
val fromKind: TimelineKind,
|
||||
val toKind: TimelineKind,
|
||||
val from: DaySpan,
|
||||
val to: DaySpan,
|
||||
val fromState: StateFlow<WeekUiState>,
|
||||
val toState: StateFlow<WeekUiState>,
|
||||
) {
|
||||
val progress = Animatable(0f)
|
||||
|
||||
/** Whether the columns change size a lot, from a single day to a week or back, and take longer for it. */
|
||||
val isLong: Boolean get() = maxOf(from.count, to.count) > LONG_SWITCH_RATIO * minOf(from.count, to.count)
|
||||
|
||||
/** Where each day's events come from: the page it was on, else the page it goes to. */
|
||||
fun sourceOf(date: LocalDate): Side = if (date in from.start..from.end) Side.From else Side.To
|
||||
|
||||
enum class Side { From, To }
|
||||
}
|
||||
|
||||
private const val LONG_SWITCH_RATIO = 3
|
||||
@@ -0,0 +1,107 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.plus
|
||||
|
||||
/** Pages the week pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */
|
||||
internal const val WEEK_PAGE_COUNT: Int = 20_000
|
||||
|
||||
private const val EPOCH_PAGE = WEEK_PAGE_COUNT / 2
|
||||
|
||||
/** The week at [EPOCH_PAGE]; any date works, it only has to stay fixed. */
|
||||
private val PAGE_EPOCH = LocalDate(2000, 1, 3)
|
||||
|
||||
/** First day of the week shown on pager [page], with weeks starting on [firstDay]. */
|
||||
internal fun weekStartForPage(page: Int, firstDay: DayOfWeek): LocalDate =
|
||||
PAGE_EPOCH.startOfWeek(firstDay).plus((page - EPOCH_PAGE) * 7, DateTimeUnit.DAY)
|
||||
|
||||
/** The pager page whose week contains [date], with weeks starting on [firstDay]. */
|
||||
internal fun weekPageFor(date: LocalDate, firstDay: DayOfWeek): Int {
|
||||
val days = PAGE_EPOCH.startOfWeek(firstDay).daysUntil(date.startOfWeek(firstDay))
|
||||
return (EPOCH_PAGE + days / 7).coerceIn(0, WEEK_PAGE_COUNT - 1)
|
||||
}
|
||||
|
||||
/** How a timeline pager cuts the calendar into pages of [dayCount] days. */
|
||||
sealed interface PageGrid {
|
||||
val dayCount: Int
|
||||
val pageCount: Int
|
||||
|
||||
/** First day shown on pager [page]. */
|
||||
fun startForPage(page: Int): LocalDate
|
||||
|
||||
/** The pager page showing [date]. */
|
||||
fun pageFor(date: LocalDate): Int
|
||||
}
|
||||
|
||||
/** Whole weeks, starting on [firstDay]. */
|
||||
data class WeekGrid(val firstDay: DayOfWeek) : PageGrid {
|
||||
override val dayCount: Int get() = 7
|
||||
override val pageCount: Int get() = WEEK_PAGE_COUNT
|
||||
override fun startForPage(page: Int): LocalDate = weekStartForPage(page, firstDay)
|
||||
override fun pageFor(date: LocalDate): Int = weekPageFor(date, firstDay)
|
||||
}
|
||||
|
||||
/** Pages the multi-day pager spans — over three centuries at the widest page. */
|
||||
internal const val DAYS_PAGE_COUNT: Int = 40_000
|
||||
|
||||
private const val DAYS_EPOCH_PAGE = DAYS_PAGE_COUNT / 2
|
||||
|
||||
/** A fixed day the multi-day pages are counted from. */
|
||||
private val DAYS_EPOCH = LocalDate(2000, 1, 1)
|
||||
|
||||
/**
|
||||
* Runs of [dayCount] days (#193), offset [phase] days from a fixed epoch so any
|
||||
* date can start a page. The phase stays put while paging and only changes when
|
||||
* a jump moves another date to the first column.
|
||||
*/
|
||||
data class DaysGrid(override val dayCount: Int, val phase: Int) : PageGrid {
|
||||
override val pageCount: Int get() = DAYS_PAGE_COUNT
|
||||
|
||||
override fun startForPage(page: Int): LocalDate =
|
||||
DAYS_EPOCH.plus(phase + (page - DAYS_EPOCH_PAGE) * dayCount, DateTimeUnit.DAY)
|
||||
|
||||
override fun pageFor(date: LocalDate): Int =
|
||||
(DAYS_EPOCH_PAGE + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount))
|
||||
.coerceIn(0, DAYS_PAGE_COUNT - 1)
|
||||
|
||||
companion object {
|
||||
/** The grid of [dayCount]-day pages on which [date] is a page's first day. */
|
||||
fun startingAt(date: LocalDate, dayCount: Int): DaysGrid =
|
||||
DaysGrid(dayCount, Math.floorMod(DAYS_EPOCH.daysUntil(date), dayCount))
|
||||
}
|
||||
}
|
||||
|
||||
/** Pages the day pager spans — a little under four centuries, centred on [DAY_EPOCH]. */
|
||||
internal const val DAY_PAGE_COUNT: Int = 140_000
|
||||
|
||||
private const val DAY_EPOCH_PAGE = DAY_PAGE_COUNT / 2
|
||||
|
||||
/** The day at [DAY_EPOCH_PAGE]; any date works, it only has to stay fixed. */
|
||||
private val DAY_EPOCH = LocalDate(2000, 1, 1)
|
||||
|
||||
/** Single days: every date starts a page of its own. */
|
||||
data object DayGrid : PageGrid {
|
||||
override val dayCount: Int get() = 1
|
||||
override val pageCount: Int get() = DAY_PAGE_COUNT
|
||||
|
||||
override fun startForPage(page: Int): LocalDate =
|
||||
DAY_EPOCH.plus(page - DAY_EPOCH_PAGE, DateTimeUnit.DAY)
|
||||
|
||||
override fun pageFor(date: LocalDate): Int =
|
||||
(DAY_EPOCH_PAGE + DAY_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the focused date goes when the pager settles on [page] (#184): kept if
|
||||
* the page still shows it, otherwise moved by the pages travelled, so it keeps
|
||||
* its place in the page — the same weekday a week on.
|
||||
*/
|
||||
internal fun PageGrid.focusOnPage(focus: LocalDate, page: Int): LocalDate {
|
||||
val start = startForPage(page)
|
||||
val end = start.plus(dayCount - 1, DateTimeUnit.DAY)
|
||||
if (focus in start..end) return focus
|
||||
return focus.plus((page - pageFor(focus)) * dayCount, DateTimeUnit.DAY).coerceIn(start, end)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,17 +9,22 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.displayPriority
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
|
||||
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.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNot
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
@@ -29,6 +34,7 @@ import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
@@ -37,19 +43,42 @@ import java.util.Locale
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.abs
|
||||
|
||||
const val MINUTES_PER_DAY: Int = 24 * 60
|
||||
|
||||
/** How far from the requested page [PagedTimelineViewModel.span] keeps other pages cached. */
|
||||
private const val SPAN_CACHE_DAYS = 28
|
||||
|
||||
/** A run of [count] days from [start]: what one timeline page shows. */
|
||||
data class DaySpan(val start: LocalDate, val count: Int)
|
||||
|
||||
/** How the pager cuts the calendar, and the page the anchor sits on. */
|
||||
data class PagerPosition(val grid: PageGrid, val page: Int)
|
||||
|
||||
/** Where [anchor] sits under [rule]. */
|
||||
fun positionOf(anchor: LocalDate, rule: (LocalDate) -> PageGrid): PagerPosition =
|
||||
rule(anchor).let { PagerPosition(it, it.pageFor(anchor)) }
|
||||
|
||||
/**
|
||||
* A timeline paged in runs of days: the week view, and the multi-day view (#193).
|
||||
* The pager leads; the anchor follows it once it settles, and the grid it is
|
||||
* cut by is derived from the anchor through [gridRule], so a subclass decides
|
||||
* whether a jump re-frames the pages or only moves between them.
|
||||
*
|
||||
* @param gridRule the grid to use for a given anchor, as the settings behind it change.
|
||||
* @param initialRule the rule used until [gridRule] first emits.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class WeekViewModel @Inject constructor(
|
||||
abstract class PagedTimelineViewModel(
|
||||
private val repository: CalendarRepository,
|
||||
settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
private val io: CoroutineDispatcher,
|
||||
gridRule: Flow<(LocalDate) -> PageGrid>,
|
||||
initialRule: (LocalDate) -> PageGrid,
|
||||
) : ViewModel() {
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
private val locale: Locale = Locale.getDefault()
|
||||
|
||||
/** Whether to fade events that have already finished (display concern only). */
|
||||
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||
@@ -62,78 +91,91 @@ class WeekViewModel @Inject constructor(
|
||||
private val todayDate: LocalDate
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
/** First day of the week, from the Settings preference (AUTO → locale). */
|
||||
private val weekStart: StateFlow<DayOfWeek> = settingsPrefs.weekStart
|
||||
.map { it.resolveFirstDay(locale) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = DayOfWeek.MONDAY,
|
||||
)
|
||||
|
||||
// Anchor is a representative day inside the visible week; the actual week
|
||||
// start is derived against [weekStart], so changing the first-day preference
|
||||
// re-frames the same week instead of jumping.
|
||||
// A day on the visible page; the page itself comes from the grid, so a
|
||||
// settings change re-frames the same days instead of jumping.
|
||||
private val _anchor = MutableStateFlow(todayDate)
|
||||
val anchor: StateFlow<LocalDate> = _anchor
|
||||
|
||||
val weekStartDate: StateFlow<LocalDate> =
|
||||
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
|
||||
/**
|
||||
* The grid for a given anchor, held eagerly so a screen can place its pager
|
||||
* on its very first frame rather than a frame after the anchor moved.
|
||||
*/
|
||||
val rule: StateFlow<(LocalDate) -> PageGrid> =
|
||||
gridRule.stateIn(viewModelScope, SharingStarted.Eagerly, initialRule)
|
||||
|
||||
private val position: StateFlow<PagerPosition> =
|
||||
combine(_anchor, rule) { anchor, rule -> positionOf(anchor, rule) }
|
||||
.distinctUntilChanged()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = todayDate.startOfWeek(DayOfWeek.MONDAY),
|
||||
initialValue = positionOf(todayDate, initialRule),
|
||||
)
|
||||
|
||||
/**
|
||||
* The anchor page's state. Once loaded it never falls back to [WeekUiState.Loading]:
|
||||
* moving the anchor keeps the last result until the new page arrives, since
|
||||
* the pages draw their own days and this only gates the failure screen.
|
||||
*/
|
||||
val state: StateFlow<WeekUiState> =
|
||||
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
|
||||
position.map { DaySpan(it.grid.startForPage(it.page), it.grid.dayCount) }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { start ->
|
||||
val range = weekRange(start, zone)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
) { calendars, instances ->
|
||||
buildState(start, calendars, instances)
|
||||
}
|
||||
}
|
||||
.flatMapLatest { span -> span(span).filterNot { it is WeekUiState.Loading } }
|
||||
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = WeekUiState.Loading,
|
||||
)
|
||||
|
||||
fun goToPrev() {
|
||||
_anchor.value = _anchor.value.minus(7, DateTimeUnit.DAY)
|
||||
private val spans = PageStateCache<DaySpan, WeekUiState>(
|
||||
scope = viewModelScope,
|
||||
initial = WeekUiState.Loading,
|
||||
// Far enough to cover the pages either side and a swipe back, so paging
|
||||
// through a year doesn't keep a year of queries around.
|
||||
keep = { cached, requested ->
|
||||
cached.count == requested.count &&
|
||||
abs(cached.start.daysUntil(requested.start)) <= SPAN_CACHE_DAYS
|
||||
},
|
||||
) { span ->
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(spanRange(span.start, span.count, zone)),
|
||||
) { calendars, instances ->
|
||||
buildState(span, calendars, instances)
|
||||
}
|
||||
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
}
|
||||
|
||||
fun goToNext() {
|
||||
_anchor.value = _anchor.value.plus(7, DateTimeUnit.DAY)
|
||||
/** The days of one page, shared between that page and [state]. Main thread only. */
|
||||
fun span(span: DaySpan): StateFlow<WeekUiState> = spans.get(span)
|
||||
|
||||
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
|
||||
fun onPageSettled(page: Int) {
|
||||
val grid = rule.value(_anchor.value)
|
||||
if (grid.pageFor(_anchor.value) != page) _anchor.value = grid.startForPage(page)
|
||||
}
|
||||
|
||||
fun goToToday() {
|
||||
_anchor.value = todayDate
|
||||
}
|
||||
|
||||
/** Jump to the week containing [date] (drawer jump-to-date). */
|
||||
fun goToDate(date: LocalDate) {
|
||||
/** Move the anchor to [date], re-framing the pages if the grid depends on it. */
|
||||
fun jumpTo(date: LocalDate) {
|
||||
_anchor.value = date
|
||||
}
|
||||
|
||||
fun goToToday() = jumpTo(todayDate)
|
||||
fun goToDate(date: LocalDate) = jumpTo(date)
|
||||
|
||||
private fun buildState(
|
||||
start: LocalDate,
|
||||
span: DaySpan,
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): WeekUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return WeekUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
|
||||
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) }
|
||||
val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) }
|
||||
val allDay = instances.filter { it.isAllDay }
|
||||
val timed = instances.filterNot { it.isAllDay }
|
||||
return WeekUiState.Success(
|
||||
weekStart = start,
|
||||
weekStart = span.start,
|
||||
today = todayDate,
|
||||
days = days,
|
||||
allDaySpans = layoutAllDay(allDay, days, zone),
|
||||
@@ -142,6 +184,58 @@ class WeekViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Whole weeks, starting on the first day from Settings (AUTO → locale). */
|
||||
@HiltViewModel
|
||||
class WeekViewModel @Inject constructor(
|
||||
repository: CalendarRepository,
|
||||
settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher io: CoroutineDispatcher,
|
||||
) : PagedTimelineViewModel(
|
||||
repository = repository,
|
||||
settingsPrefs = settingsPrefs,
|
||||
io = io,
|
||||
gridRule = settingsPrefs.weekStart.map { pref ->
|
||||
val grid = WeekGrid(pref.resolveFirstDay(Locale.getDefault()))
|
||||
val rule: (LocalDate) -> PageGrid = { grid }
|
||||
rule
|
||||
},
|
||||
initialRule = { WeekGrid(DayOfWeek.MONDAY) },
|
||||
)
|
||||
|
||||
/**
|
||||
* The multi-day view (#193): runs of the day count from Settings, with the
|
||||
* anchor always a page's first day, so today — or a date jumped to — opens in
|
||||
* the first column.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class MultiDayViewModel @Inject constructor(
|
||||
repository: CalendarRepository,
|
||||
settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher io: CoroutineDispatcher,
|
||||
) : PagedTimelineViewModel(
|
||||
repository = repository,
|
||||
settingsPrefs = settingsPrefs,
|
||||
io = io,
|
||||
gridRule = settingsPrefs.multiDayCount.map { count ->
|
||||
{ anchor: LocalDate -> DaysGrid.startingAt(anchor, count) }
|
||||
},
|
||||
initialRule = { DaysGrid.startingAt(it, SettingsPrefs.DEFAULT_MULTI_DAY_COUNT) },
|
||||
)
|
||||
|
||||
/** The day view: the timeline one day at a time. */
|
||||
@HiltViewModel
|
||||
class DayViewModel @Inject constructor(
|
||||
repository: CalendarRepository,
|
||||
settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher io: CoroutineDispatcher,
|
||||
) : PagedTimelineViewModel(
|
||||
repository = repository,
|
||||
settingsPrefs = settingsPrefs,
|
||||
io = io,
|
||||
gridRule = flowOf { _: LocalDate -> DayGrid },
|
||||
initialRule = { DayGrid },
|
||||
)
|
||||
|
||||
/**
|
||||
* Lay out all-day events as connected horizontal spans across the visible week.
|
||||
* Each event becomes one [AllDaySpan] from its first to its last covered column;
|
||||
@@ -161,14 +255,20 @@ internal fun layoutAllDay(
|
||||
}
|
||||
// Declined bars are packed after every other one, so on any day they
|
||||
// cover they land in a lane below it (#230).
|
||||
.sortedWith(compareBy({ it.event.isDeclined }, { it.startCol }, { it.endCol }))
|
||||
.sortedWith(
|
||||
compareBy<Raw> { it.event.displayPriority(zone) }
|
||||
.thenBy { it.startCol }
|
||||
.thenBy { it.endCol }
|
||||
.thenBy { it.event.start }
|
||||
.thenBy { it.event.instanceId },
|
||||
)
|
||||
|
||||
// What each lane already holds, rather than just how far right it reaches.
|
||||
// A single "last occupied column" only answers correctly while spans arrive
|
||||
// in non-decreasing start order, which the declined-last rule above breaks:
|
||||
// a Monday bar seated after a Wednesday one would be refused a lane it is
|
||||
// nowhere near, and each wasted lane costs the all-day strip a whole row and
|
||||
// pushes a bar closer to the month grid's MAX_EVENT_ROWS cap. Seven columns
|
||||
// pushes a bar closer to the month grid's lane cap. Seven columns
|
||||
// and a handful of bars, so the scan is cheaper than the sort above it.
|
||||
val laneCols = ArrayList<MutableList<IntRange>>()
|
||||
return raw.map { r ->
|
||||
@@ -195,10 +295,10 @@ internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
|
||||
return minus(offset, DateTimeUnit.DAY)
|
||||
}
|
||||
|
||||
/** Half-open instant range covering the seven days starting at [start]. */
|
||||
internal fun weekRange(start: LocalDate, zone: TimeZone): ClosedRange<Instant> {
|
||||
/** Instant range covering the [count] days starting at [start]. */
|
||||
internal fun spanRange(start: LocalDate, count: Int, zone: TimeZone): ClosedRange<Instant> {
|
||||
val from = start.atStartOfDayIn(zone)
|
||||
val to = start.plus(6, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
|
||||
val to = start.plus(count - 1, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
|
||||
return from..to
|
||||
}
|
||||
|
||||
|
||||
@@ -32,10 +32,13 @@ internal fun WeekViewPreview(
|
||||
weekStart: DayOfWeek,
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
dayCount: Int = 7,
|
||||
) {
|
||||
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 state = remember(today, weekStart, dayCount, zone) {
|
||||
sampleWeekState(today, weekStart, dayCount, zone)
|
||||
}
|
||||
val scrollState = rememberScrollState()
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
@@ -46,6 +49,8 @@ internal fun WeekViewPreview(
|
||||
ScaledViewPreview(height = height, modifier = modifier) {
|
||||
WeekSuccess(
|
||||
state = state,
|
||||
showWeekNumber = dayCount == 7,
|
||||
headerBias = if (dayCount == 1) TimelineKind.Day.headerBias else 0f,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = state.allDayStripHeight(),
|
||||
dragController = rememberTimelineDragController(),
|
||||
@@ -61,10 +66,11 @@ internal fun WeekViewPreview(
|
||||
private fun sampleWeekState(
|
||||
today: LocalDate,
|
||||
weekStart: DayOfWeek,
|
||||
dayCount: Int,
|
||||
zone: TimeZone,
|
||||
): WeekUiState.Success {
|
||||
val start = today.startOfWeek(weekStart)
|
||||
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
|
||||
val start = if (dayCount == 7) today.startOfWeek(weekStart) else today
|
||||
val days = (0 until dayCount).map { start.plus(it, DateTimeUnit.DAY) }
|
||||
val events = sampleTimelineEvents(days, today, zone)
|
||||
return WeekUiState.Success(
|
||||
weekStart = start,
|
||||
|
||||
@@ -43,16 +43,16 @@
|
||||
<string name="event_detail_edit">تعديل</string>
|
||||
<string name="event_detail_delete">حذف</string>
|
||||
<string name="event_detail_share">مشاركة</string>
|
||||
<string name="event_detail_duplicate">نسخ</string>
|
||||
<string name="event_detail_duplicate">تكرار</string>
|
||||
<string name="event_share_chooser_title">مشاركة الحدث</string>
|
||||
<string name="day_today_action">اليوم</string>
|
||||
<string name="week_today_action">هذا الأسبوع</string>
|
||||
<string name="week_number_label">أسبوع</string>
|
||||
<string name="event_detail_back">العودة</string>
|
||||
<string name="event_detail_back">الرجوع</string>
|
||||
<string name="month_a11y_today_prefix">اليوم</string>
|
||||
<string name="event_share_failed">تعذّر مشاركة هذا الحدث.</string>
|
||||
<string name="event_delete_title">حذف الحدث؟</string>
|
||||
<string name="event_delete_body">الحدث أُزيل من تقويمك ومن كل جهاز تتم مزامنته معه.</string>
|
||||
<string name="event_delete_body">تتم إزالة الحدث من تقويمك ومن كل جهاز تتم مزامنته معه.</string>
|
||||
<string name="event_delete_recurring_title">حذف الحدث المتكرّر</string>
|
||||
<string name="event_delete_option_occurrence">فقط هذا الحدث</string>
|
||||
<string name="event_delete_option_following">هذا وجميع الأحداث اللاحقة</string>
|
||||
@@ -172,7 +172,7 @@
|
||||
<string name="event_attendee_optional">اختياري</string>
|
||||
<string name="reminder_default">التذكير الافتراضي</string>
|
||||
<string name="event_detail_self_response">ردَّك: %1$s</string>
|
||||
<string name="event_attendee_resource">المُنسِّق</string>
|
||||
<string name="event_attendee_resource">المُورِّد</string>
|
||||
<string name="reminder_at_time">في وقت الحدث</string>
|
||||
<string name="event_untitled">(بدون عنوان)</string>
|
||||
<string name="reminder_channel_name">تذكيرات الحدث</string>
|
||||
@@ -319,8 +319,8 @@
|
||||
<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="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>
|
||||
@@ -341,12 +341,12 @@
|
||||
<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_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_event_duration_hint">كم مدة استمرار حدث جديد إلى أي تقوم بتغيير وقت انتهائه. لا تتأثر أحداث طوال اليوم.</string>
|
||||
<string name="settings_section_notifications">الإشعارات</string>
|
||||
<string name="settings_reminders_hint">ترى التذكيرات مرتين؟ هناك تطبيق تقويم آخر ينشرهم أيضًا — قم بإيقافهم في أحد من الاثنين.</string>
|
||||
<string name="settings_group_look">المظهر و السلوك</string>
|
||||
@@ -361,7 +361,6 @@
|
||||
<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>
|
||||
@@ -375,7 +374,7 @@
|
||||
<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="settings_section_special_dates">التواريخ الخاصة لجهات الاتصال</string>
|
||||
<string name="calendars_title">التقويمات</string>
|
||||
<string name="calendars_add">إضافة تقويم</string>
|
||||
<string name="settings_section_calendars">التقويمات</string>
|
||||
@@ -492,7 +491,7 @@
|
||||
<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_warning_recurrence">تم تخطي بعض التكرارات المعدلة للأحداث المتكررة.</string>
|
||||
<string name="import_close">إغلاق</string>
|
||||
<string name="import_done_skipped_label">التكرارات</string>
|
||||
<string name="import_done_added_label">أُضيف</string>
|
||||
@@ -502,9 +501,7 @@
|
||||
<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>
|
||||
@@ -556,7 +553,7 @@
|
||||
<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_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>
|
||||
@@ -663,7 +660,7 @@
|
||||
<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="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>
|
||||
@@ -683,8 +680,8 @@
|
||||
<string name="settings_font_specimen_body">مراجعة الفريق في 10:00، ثم الغداء مع روبن في المقهى في ماركتبلاتز.</string>
|
||||
<string name="settings_section_widgets">الأدوات المصغّرة والعناصر</string>
|
||||
<string name="settings_widget_size_hint">مدى حجم النص الذي يعرضه جدول أداة الشاشة الرئيسية. أداة الشهر ليس لديه إعداد — تتناسب شبكته دائمًا نفسه للمساحة التي تمنحها إيّاه.</string>
|
||||
<string name="settings_agenda_widget_range_hint">كم مدى يعرض جدول أداة الشاشة الرئيسية الأحداث.</string>
|
||||
<string name="settings_agenda_range_hint">كم مدى يعرض شاشة الجدول الأحداث.</string>
|
||||
<string name="settings_agenda_widget_range_hint">كم مدى يُدرج جدول أداة الشاشة الرئيسية الأحداث.</string>
|
||||
<string name="settings_agenda_range_hint">كم مدى يُدرج شاشة الجدول الأحداث.</string>
|
||||
<string name="app_name">Calendula</string>
|
||||
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
|
||||
<string name="settings_special_dates_show_year">إظهار السنة</string>
|
||||
@@ -696,13 +693,46 @@
|
||||
<string name="font_lora">Lora</string>
|
||||
<string name="font_jetbrains_mono">JetBrains Mono</string>
|
||||
<string name="onboarding_backup_benefit_folder_title">مجلد تختاره</string>
|
||||
<string name="import_warning_timezone">منطقة زمنية غير معروفة ترجع إلى الخاصة بجهازك.</string>
|
||||
<string name="settings_calendar_reminders_managed_hint">تعيين في تواريخ جهات الاتصال الخاصة</string>
|
||||
<string name="settings_special_dates_disable_all_message">يؤدي هذا إلى حذف تقويمات تواريخ جهات الاتصال وأحداثها. سيتم فقدان أي تذكيرات أو ملاحظات أضفتها إليها.</string>
|
||||
<string name="import_warning_timezone">تمت إعادة منطقة زمنية غير معروفة إلى الخاصّة بجهازك.</string>
|
||||
<string name="settings_calendar_reminders_managed_hint">تعيين في التواريخ الخاصة لجهات الاتصال</string>
|
||||
<string name="settings_special_dates_disable_all_message">يؤدي هذا لحذف تقويمات تواريخ جهات الاتصال وأحداثها. سيتم فقدان أي تذكيرات أو ملاحظات أضفتها إليها.</string>
|
||||
<string name="settings_special_dates_type_anniversary">الذكرات السنوية</string>
|
||||
<string name="settings_special_dates_template_hint">استخدم {name} لجهة الاتصال و{year} للسنة (سنة الميلاد، أو سنة بدء الذكرى السنوية؛ مخفية عندما تكون غير معروفة).</string>
|
||||
<string name="settings_special_dates_template">صيغة العنوان</string>
|
||||
<string name="settings_calendar_reminders_hint">تجاوز الافتراضي لكل تقويم — بشكل منفصل للمحدد بوقت وأحداث طوال اليوم. يمكن لتقويم الاحتفاظ بالافتراضي، إسقاطه أو تعيين خاصّته.</string>
|
||||
<string name="event_move_occurrence_only">سيؤدي نقل السلسلة بأكملها لتغيير الأيام التي تقع فيها، لذلك يمكن نقل هذا الحدث فقط.</string>
|
||||
<string name="agenda_empty_title">أنت على اطلاع</string>
|
||||
<string name="event_move_occurrence_only">سيؤدي نقل السلسلة بأكملها لتغيير أيٍّ الأيام التي تقع فيها، لذلك يمكن نقل هذا الحدث فقط.</string>
|
||||
<string name="agenda_empty_title">أنت بالكل على آخر اطلاع</string>
|
||||
<string name="reminder_after">%1$s بعد</string>
|
||||
<string name="import_done_failed_label">لم تتم الإضافة</string>
|
||||
<string name="import_done_failed_note">تعذّر إضافة بعض الأحداث وتم استبعادها.</string>
|
||||
<string name="import_done_stopped_title">تم إيقاف الاستيراد</string>
|
||||
<string name="import_warning_tasks">تمت إضافة المهام في هذا الملف كأحداث.</string>
|
||||
<string name="import_warning_recurrence_repaired">تم إصلاح قاعدة تكرار معطّلة.</string>
|
||||
<plurals name="import_done_stopped">
|
||||
<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_failed">
|
||||
<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="state_failure_all_hidden">تم تعطيل جميع تقويماتك.</string>
|
||||
<string name="state_failure_all_hidden_action">إدارة التقويمات</string>
|
||||
<string name="field_copy_action">نسخ</string>
|
||||
<string name="field_copied">تم النسخ إلى الحافظة</string>
|
||||
<string name="field_copy_failed">تعذّر نسخ ذلك</string>
|
||||
<string name="event_detail_title">العنوان</string>
|
||||
<string name="settings_licences">تراخيص مفتوحة المصدر</string>
|
||||
<string name="settings_licences_subtitle">المشاريع التي بنيت Calendula عليها</string>
|
||||
<string name="licences_title">تراخيص مفتوحة المصدر</string>
|
||||
<string name="licences_intro">يتضمن Calendula العمل أدناه. اضغط على أي إدخال للحصول على شفرة المصدر الخاصة به.</string>
|
||||
<string name="licences_footer">يتم تضمين كل مشروع هنا دون تعديل، بموجب الترخيص الموضح.</string>
|
||||
</resources>
|
||||
|
||||
@@ -584,4 +584,84 @@
|
||||
<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_interval">Інтэрвал</string>
|
||||
<string name="calendars_auto_backup_every">Кожн. %1$s</string>
|
||||
<string name="calendars_auto_backup_interval_min">Мінімум 30 хв.</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="one">Экспартавана %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="import_done_added_label">Дадана</string>
|
||||
<string name="import_done_skipped_label">Дублікаты</string>
|
||||
<string name="import_close">Закрыць</string>
|
||||
<string name="import_warning_recurrence">Былі прамінутыя некаторыя зьмененыя паўторныя падзеі.</string>
|
||||
<string name="import_warning_no_start">Падзея бяз часу пачатку была прамінутая.</string>
|
||||
<string name="import_warning_attendees">Сьпісы наведвальнікаў не былі імпартаваныя.</string>
|
||||
<string name="import_warning_timezone">Замест невядомага часавага поясу ўжыты сыстэмны для вашай прылады.</string>
|
||||
<string name="import_button">Імпарт</string>
|
||||
<plurals name="import_title_count">
|
||||
<item quantity="one">Імпартуецца %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="one">%d падзея ў гэтым файле.</item>
|
||||
<item quantity="few">Падзеяў у гэтым файле: %d.</item>
|
||||
<item quantity="many">Падзеяў у гэтым файле: %d.</item>
|
||||
<item quantity="other">Падзеяў у гэтым файле: %d.</item>
|
||||
</plurals>
|
||||
<plurals name="import_action">
|
||||
<item quantity="one">Імпарт %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="one">Імпартавана %d падзея.</item>
|
||||
<item quantity="few">Імпартавана падзеяў: %d.</item>
|
||||
<item quantity="many">Імпартавана падзеяў: %d.</item>
|
||||
<item quantity="other">Імпартавана падзеяў: %d.</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_skipped">
|
||||
<item quantity="one">Прамінута %d падзея (ужо ёсьць у гэтым календары).</item>
|
||||
<item quantity="few">Прамінута падзеяў: %d (ужо ёсьць у гэтым календары).</item>
|
||||
<item quantity="many">Прамінута падзеяў: %d (ужо ёсьць у гэтым календары).</item>
|
||||
<item quantity="other">Прамінута падзеяў: %d (ужо ёсьць у гэтым календары).</item>
|
||||
</plurals>
|
||||
<string name="shortcut_new_event_short">Новая падзея</string>
|
||||
<string name="shortcut_new_event_long">Стварыць новую падзею</string>
|
||||
<string name="qs_tile_new_event_label">Новая падзея</string>
|
||||
<string name="settings_qs_tile">Дадаць плітку хуткіх наладаў</string>
|
||||
<string name="settings_qs_tile_hint">Дадаць плітку «Новая падзея» на панэль хуткіх наладаў.</string>
|
||||
<string name="crash_dialog_title">Крах %1$s</string>
|
||||
<string name="crash_dialog_message">Апошнім разам %1$s нечакана закрылася. Вы можаце дапамагчы выправіць гэта, адправіўшы справаздачу аб праблеме. Яна застанецца на вашай прыладзе, пакуль вы не вырашыце падзяліцца ёю, і ня ўтрымлівае асабістых даных або зьмесціва календара — толькі тэхнічныя падрабязнасьці, паданыя ніжэй.</string>
|
||||
<string name="crash_dialog_report">Справаздача</string>
|
||||
<string name="crash_dialog_dismiss">Ня зараз</string>
|
||||
<string name="crash_report_clip_label">Справаздача аб краху %1$s</string>
|
||||
<string name="crash_report_copied">Справаздача скапіявана ў буфэр абмену</string>
|
||||
<string name="crash_report_open_failed">Не атрымалася адкрыць сыстэму адсочваньня праблемаў. Справаздача знаходзіцца ў буфэры абмену.</string>
|
||||
<string name="special_dates_calendar_birthday">Дні нараджэньня</string>
|
||||
<string name="special_dates_calendar_anniversary">Юбілейныя даты</string>
|
||||
<string name="special_dates_calendar_custom">Асаблівыя даты</string>
|
||||
<string name="special_dates_default_title_birthday">Дзень нараджэньня {name} ({year})</string>
|
||||
<string name="special_dates_default_title_anniversary">Юбілейная дата {name} ({year})</string>
|
||||
<string name="special_dates_default_title_custom">{name}</string>
|
||||
</resources>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<string name="event_detail_edit">Upravit</string>
|
||||
<string name="event_detail_delete">Odstranit</string>
|
||||
<string name="event_detail_share">Sdílet</string>
|
||||
<string name="event_detail_duplicate">Kopírovat</string>
|
||||
<string name="event_detail_duplicate">Duplikovat</string>
|
||||
<string name="event_share_chooser_title">Sdílet událost</string>
|
||||
<string name="event_share_failed">Tuto událost nelze sdílet.</string>
|
||||
<string name="event_delete_title">Odstranit událost?</string>
|
||||
@@ -73,7 +73,7 @@
|
||||
<string name="event_edit_close">Zavřít</string>
|
||||
<string name="event_edit_save">Uložit</string>
|
||||
<string name="event_edit_title_hint">Přidat název</string>
|
||||
<string name="event_edit_managed_hint">Spravováno aplikací „%1$s“ — název, datum a opakování je synchronizováno z vašich kontaktů. Upomínky, polohu a poznámky si můžete upravit sami.</string>
|
||||
<string name="event_edit_managed_hint">Spravováno aplikací „%1$s“ — název, datum a opakování je synchronizováno z vašich kontaktů. Připomínky, polohu a poznámky si můžete upravit sami.</string>
|
||||
<string name="event_edit_starts">Začátek</string>
|
||||
<string name="event_edit_ends">Konec</string>
|
||||
<string name="event_edit_error_end_before_start">Končí dříve, než začíná</string>
|
||||
@@ -82,8 +82,8 @@
|
||||
<string name="event_edit_write_denied">Calendula pro vytváření událostí vyžaduje oprávnění k zápisu</string>
|
||||
<string name="event_edit_more_fields">další pole</string>
|
||||
<string name="event_edit_add">Přidat</string>
|
||||
<string name="event_edit_add_reminder">Přidat upomínku</string>
|
||||
<string name="event_edit_remove_reminder">Vymazat upomínku</string>
|
||||
<string name="event_edit_add_reminder">Přidat připomínku</string>
|
||||
<string name="event_edit_remove_reminder">Vymazat připomínku</string>
|
||||
<string name="event_edit_attendees">Hosté</string>
|
||||
<string name="event_edit_add_guest">Přidat hosta</string>
|
||||
<string name="event_edit_add_guest_hint">Přidat hosta e-mailem…</string>
|
||||
@@ -106,7 +106,7 @@
|
||||
<string name="event_edit_timezone_search">Vyhledat časové pásmo</string>
|
||||
<string name="event_edit_timezone_recent">Nedávné</string>
|
||||
<string name="event_edit_timezone_all">Všechna časová pásma</string>
|
||||
<string name="event_edit_timezone_none">Žádné časové pásmo neodpovídá “%1$s”</string>
|
||||
<string name="event_edit_timezone_none">Žádné časové pásmo neodpovídá „%1$s“</string>
|
||||
<string name="event_edit_timezone_local_time">%1$s vašeho času</string>
|
||||
<string name="event_edit_color">Barva</string>
|
||||
<string name="event_edit_color_default">Barva kalendáře</string>
|
||||
@@ -115,7 +115,7 @@
|
||||
<string name="event_edit_color_unsupported">Pro tento kalendář není k dispozici</string>
|
||||
<string name="event_edit_color_unsupported_hint">Tento kalendář nemá žádnou sadu barev. Vlastní barvy kalendářů můžete povolit v nastavení.</string>
|
||||
<string name="event_edit_color_sync_warning">Tato barva může být během příští synchronizace kalendáře ztracena nebo přepsána.</string>
|
||||
<string name="event_edit_conflict_title">Ke změně události došlo na jiném místě.</string>
|
||||
<string name="event_edit_conflict_title">Ke změně události došlo na jiném místě</string>
|
||||
<string name="event_edit_conflict_body">Během úpravy došlo ke změně události — v rámci synchronizace nebo pomocí jiné aplikací. Co se má stát s vašimi změnami?</string>
|
||||
<string name="event_edit_conflict_overwrite">Uložit změny</string>
|
||||
<string name="event_edit_conflict_overwrite_hint">Pouze u upravených polí dojde k přepsání vnějšího zdroje</string>
|
||||
@@ -123,13 +123,13 @@
|
||||
<string name="event_edit_conflict_discard_hint">Událost zůstává beze změny</string>
|
||||
<string name="event_edit_gone_title">Událost byla odstraněna</string>
|
||||
<string name="event_edit_gone_body">Během úpravy došlo k odstranění události, například pomocí jiného zařízení. Vaše změny již nelze uložit.</string>
|
||||
<string name="import_reminder_prompt_title">Chcete použít výchozí upomínku?</string>
|
||||
<string name="import_reminder_prompt_body_none">Tato událost byla importována bez nastavené upomínky.</string>
|
||||
<string name="import_reminder_prompt_title">Chcete použít výchozí připomínku?</string>
|
||||
<string name="import_reminder_prompt_body_none">Tato událost byla importována bez nastavené připomínky.</string>
|
||||
<plurals name="import_reminder_prompt_body_existing">
|
||||
<item quantity="one">Tato událost byla importována s %1$d upomínkou.</item>
|
||||
<item quantity="few">Tato událost byla importována s %1$d upomínkami.</item>
|
||||
<item quantity="many">Tato událost byla importována s %1$d upomínkami.</item>
|
||||
<item quantity="other">Tato událost byla importována s %1$d upomínkami.</item>
|
||||
<item quantity="one">Tato událost byla importována s %1$d připomínkou.</item>
|
||||
<item quantity="few">Tato událost byla importována s %1$d připomínkami.</item>
|
||||
<item quantity="many">Tato událost byla importována s %1$d připomínkami.</item>
|
||||
<item quantity="other">Tato událost byla importována s %1$d připomínkami.</item>
|
||||
</plurals>
|
||||
<string name="import_reminder_prompt_apply">Nastavit výchozí</string>
|
||||
<string name="import_reminder_prompt_keep">Nechat beze změny</string>
|
||||
@@ -155,7 +155,7 @@
|
||||
<string name="event_detail_all_day">Celý den</string>
|
||||
<string name="event_detail_calendar">Kalendář</string>
|
||||
<string name="event_detail_calendar_unknown">Neznámý kalendář</string>
|
||||
<string name="event_detail_location">Umístění</string>
|
||||
<string name="event_detail_location">Místo</string>
|
||||
<string name="event_detail_description">Popis</string>
|
||||
<string name="event_detail_attendees">Účastníci</string>
|
||||
<string name="event_detail_recurrence">Opakování</string>
|
||||
@@ -169,7 +169,7 @@
|
||||
<string name="recurrence_every_n_months">Každý %1$d. měsíc</string>
|
||||
<string name="recurrence_every_n_years">Každý %1$d. rok</string>
|
||||
<string name="recurrence_on_days">%1$s v %2$s</string>
|
||||
<string name="recurrence_with_until">%1$s do%2$s</string>
|
||||
<string name="recurrence_with_until">%1$s do %2$s</string>
|
||||
<string name="recurrence_with_count">%1$s, %2$d opakování</string>
|
||||
<string name="event_detail_not_found">Tato událost již neexistuje.</string>
|
||||
<string name="event_attendee_accepted">Přijato</string>
|
||||
@@ -177,7 +177,7 @@
|
||||
<string name="event_attendee_tentative">Předběžné</string>
|
||||
<string name="event_attendee_needs_action">Bez odezvy</string>
|
||||
<string name="event_attendee_unknown">—</string>
|
||||
<string name="event_detail_reminders">Upomínky</string>
|
||||
<string name="event_detail_reminders">Připomínky</string>
|
||||
<string name="event_detail_timezone">Časové pásmo</string>
|
||||
<string name="event_status_tentative">Přeběžné</string>
|
||||
<string name="event_status_cancelled">Zrušeno</string>
|
||||
@@ -190,10 +190,10 @@
|
||||
<string name="event_access_confidential_summary">Označeno jako utajené; záleží na daném účtu kalendáře, jak s tímto štítkem naloží</string>
|
||||
<string name="event_attendee_organizer">Pořadatel</string>
|
||||
<string name="event_attendee_optional">Volitelné</string>
|
||||
<string name="event_attendee_resource">Zdroj</string>
|
||||
<string name="event_attendee_resource">Prostředky</string>
|
||||
<string name="event_detail_self_response">Vaše odpověď: %1$s</string>
|
||||
<string name="reminder_at_time">V době konání události</string>
|
||||
<string name="reminder_default">Výchozí upomínka</string>
|
||||
<string name="reminder_default">Výchozí připomínka</string>
|
||||
<plurals name="reminder_minutes">
|
||||
<item quantity="one">%d minutu předtím</item>
|
||||
<item quantity="few">%d minuty předtím</item>
|
||||
@@ -261,17 +261,17 @@
|
||||
<string name="onboarding_done_title">Vše je připraveno</string>
|
||||
<string name="onboarding_done_body">Vaše kalendáře jsou připraveny. Veškeré volby lze později změnit v nastavení.</string>
|
||||
<string name="onboarding_done_button">Otevřít můj kalendář</string>
|
||||
<string name="reminder_channel_name">Upomínky událostí</string>
|
||||
<string name="reminder_channel_description">Oznámení v časech upomínek vašich událostí</string>
|
||||
<string name="reminder_channel_name">Připomínky událostí</string>
|
||||
<string name="reminder_channel_description">Oznámení v časech připomínek vašich událostí</string>
|
||||
<string name="reminder_onboarding_title">Ať vám neunikne žádná událost</string>
|
||||
<string name="reminder_onboarding_body">Android sám o sobě nezobrazuje upomínky událostí — to musí zajistit aplikace kalendáře. Nechte tuto práci na Calendule.</string>
|
||||
<string name="reminder_benefit_delivery_title">Upomínky, doručené</string>
|
||||
<string name="reminder_benefit_delivery_body">Upomínky k vašim událostem vám přijdou v danou dobu jako oznámení.</string>
|
||||
<string name="reminder_onboarding_body">Android sám o sobě nezobrazuje připomínky událostí – to musí zajistit aplikace kalendáře. Nechte tuto práci na Calendule.</string>
|
||||
<string name="reminder_benefit_delivery_title">Připomínky, doručené</string>
|
||||
<string name="reminder_benefit_delivery_body">Připomínky k vašim událostem vám přijdou v danou dobu jako oznámení.</string>
|
||||
<string name="reminder_benefit_duplicates_title">Používáte ještě nějakou jinou aplikaci s kalendářem?</string>
|
||||
<string name="reminder_benefit_duplicates_body">Pokud i jiná aplikace odesílá upomínky, uvidíte je dvakrát — v jedné z aplikací je musíte vypnout.</string>
|
||||
<string name="reminder_benefit_duplicates_body">Pokud i jiná aplikace odesílá připomínky, uvidíte je dvakrát — v jedné z aplikací je musíte vypnout.</string>
|
||||
<string name="reminder_benefit_reversible_title">Lze kdykoliv změnit</string>
|
||||
<string name="reminder_benefit_reversible_body">Tento přepínač naleznete v Nastavení, v části Oznámení.</string>
|
||||
<string name="reminder_onboarding_enable_button">Zapnout upomínky</string>
|
||||
<string name="reminder_onboarding_enable_button">Zapnout připomínky</string>
|
||||
<string name="reminder_onboarding_skip_button">Později</string>
|
||||
<string name="reminder_action_snooze">Odložit</string>
|
||||
<string name="reminder_action_dismiss">Zrušit</string>
|
||||
@@ -280,7 +280,7 @@
|
||||
<string name="view_month">Měsíc</string>
|
||||
<string name="view_week">Týden</string>
|
||||
<string name="view_day">Den</string>
|
||||
<string name="view_agenda">Program</string>
|
||||
<string name="view_agenda">Agenda</string>
|
||||
<string name="view_section">Zobrazení</string>
|
||||
<string name="drawer_jump_to_date">Přejít na datum</string>
|
||||
<string name="agenda_today_action">Dnes</string>
|
||||
@@ -296,7 +296,7 @@
|
||||
<string name="search_back">Zpět</string>
|
||||
<string name="search_clear">Vymazat</string>
|
||||
<string name="search_idle_hint">Prohledávejte své události podle názvu, místa konání nebo poznámek.</string>
|
||||
<string name="search_empty">Žádná událost neodpovídá “%1$s”.</string>
|
||||
<string name="search_empty">Žádná událost neodpovídá „%1$s“.</string>
|
||||
<plurals name="search_selected_count">
|
||||
<item quantity="one">%d označený</item>
|
||||
<item quantity="few">%d označené</item>
|
||||
@@ -332,4 +332,365 @@
|
||||
<string name="widget_next_month">Následující měsíc</string>
|
||||
<string name="widget_today">Dnes</string>
|
||||
<string name="filter_title">Kalendáře</string>
|
||||
<string name="reminder_after">%1$s po</string>
|
||||
<string name="settings_title">Nastavení</string>
|
||||
<string name="settings_back">Zpět</string>
|
||||
<string name="back">Zpět</string>
|
||||
<string name="settings_section_appearance">Vzhled</string>
|
||||
<string name="settings_theme">Motiv</string>
|
||||
<string name="settings_theme_system">Systém</string>
|
||||
<string name="settings_theme_light">Světlý</string>
|
||||
<string name="settings_theme_dark">Tmavý</string>
|
||||
<string name="settings_theme_hint">Světlý nebo tmavý motiv aplikace. Volba se projeví okamžitě.</string>
|
||||
<string name="settings_theme_system_summary">Nyní je nastaven %1$s</string>
|
||||
<string name="settings_default_view">Výchozí zobrazení</string>
|
||||
<string name="settings_dynamic_color">Dynamické barvy</string>
|
||||
<string name="settings_dynamic_color_unavailable">Je zapotřepí Android 12 či novější</string>
|
||||
<string name="settings_soften_colors">Sladit barvy kalendáře</string>
|
||||
<string name="settings_soften_colors_summary">Zachovat odstín každého kalendáře, ale vyrovnat jeho jas, aby každá událost zůstala čitelná a barvy k sobě ladily. Vypnutím této funkce se zobrazí původní barvy zdrojového kalendáře.</string>
|
||||
<string name="settings_font_headings">Písmo nadpisů</string>
|
||||
<string name="settings_font_body">Písmo těla textu</string>
|
||||
<string name="settings_font_system">Výchozí nastavení systému</string>
|
||||
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
|
||||
<string name="font_lora">Lora</string>
|
||||
<string name="font_jetbrains_mono">JetBrains Mono</string>
|
||||
<string name="settings_font_choose_file">Vybrat soubor…</string>
|
||||
<string name="settings_font_custom_selected">Vlastní písmo</string>
|
||||
<string name="settings_font_import_failed">Tento soubor se nepodařilo načíst jako písmo</string>
|
||||
<string name="settings_font_specimen_heading">Čtvrtek, 14. května</string>
|
||||
<string name="settings_font_specimen_body">V 10:00 proběhne porada týmu, poté oběd s Robinem v kavárně na náměstí.</string>
|
||||
<string name="settings_week_start">Týden začíná dnem</string>
|
||||
<string name="settings_week_start_auto">Automaticky</string>
|
||||
<string name="settings_week_start_auto_summary">Nyní %1$s</string>
|
||||
<string name="settings_week_numbers">Čísla týdnů</string>
|
||||
<string name="settings_week_numbers_summary">Zobrazit čísla týdnů kalendáře v měsíčním zobrazení</string>
|
||||
<string name="settings_today_toolbar">Tlačítko „Dnes“ na panelu nástrojů</string>
|
||||
<string name="settings_today_toolbar_summary">Zobrazit na panelu nástrojů tlačítko „Přejít na dnešek“ namísto plovoucího tlačítka</string>
|
||||
<string name="settings_app_name">Název aplikace</string>
|
||||
<string name="settings_app_name_summary">Zobrazí aplikaci Calendula jako „Kalendář“ ve vašem spouštěči. Změní se pouze název ve spouštěči; ikona se po přepnutí může přesunout na jiné místo.</string>
|
||||
<string name="settings_time_format">Formát času</string>
|
||||
<string name="settings_time_format_auto">Automaticky</string>
|
||||
<string name="settings_time_format_12h">12hodinový (2:00 PM)</string>
|
||||
<string name="settings_time_format_24h">24hodinový (14:00)</string>
|
||||
<string name="settings_time_format_auto_summary">Podle systému: %1$s</string>
|
||||
<string name="settings_hour_lines">Hodinová mřížka</string>
|
||||
<string name="settings_hour_lines_summary">Zobrazit oddělovací čáru u každé hodiny v dané buňce v týdenním a denním zobrazení</string>
|
||||
<string name="settings_timeline_scale">Výška hodin</string>
|
||||
<string name="settings_timeline_scale_hint">Kolik vertikálního prostoru zabírá jedna hodina v týdenním a denním zobrazení. Toto nastavení platí pro obě zobrazení. Výšku časové osy můžete v rámci daného rozmezí nastavit pomocí gesta přiblížení dvěma prsty.</string>
|
||||
<string name="timeline_scale_fit_day">Přizpůsobit se dennímu zobrazení</string>
|
||||
<string name="timeline_scale_fit_day_summary">Všech 24 hodin na jedné obrazovce, bez nutnosti posouvání</string>
|
||||
<string name="timeline_scale_compact">Kompaktní</string>
|
||||
<string name="timeline_scale_compact_summary">Více hodin na obrazovce, menší bloky</string>
|
||||
<string name="timeline_scale_regular">Normální</string>
|
||||
<string name="timeline_scale_regular_summary">Standardní rozestup</string>
|
||||
<string name="timeline_scale_comfortable">Pohodlná</string>
|
||||
<string name="timeline_scale_comfortable_summary">Prostornější bloky, více posouvání</string>
|
||||
<string name="timeline_scale_custom">Vlastní</string>
|
||||
<string name="timeline_scale_custom_summary">Výška, na kterou jste časovou osu nastavili gestem</string>
|
||||
<string name="settings_dim_completed">Ztmavit dokončené události</string>
|
||||
<string name="settings_dim_completed_summary">Ztmavit dokončené události v měsíčním a týdenním zobrazení</string>
|
||||
<string name="settings_drag_to_reschedule">Přetažením změňte termín</string>
|
||||
<string name="settings_drag_to_reschedule_summary">Přesuňte událost přetažením na jiný den nebo čas</string>
|
||||
<string name="settings_past_events">Uplynulé události</string>
|
||||
<string name="settings_past_events_show">Zobrazit</string>
|
||||
<string name="settings_past_events_dim">Ztmavit</string>
|
||||
<string name="settings_past_events_hide">Skrýt</string>
|
||||
<string name="settings_past_events_sample_morning">Přehled týmu</string>
|
||||
<string name="settings_past_events_sample_midday">Oběd s Robinem</string>
|
||||
<string name="settings_past_events_sample_evening">Zkouška sboru</string>
|
||||
<string name="settings_agenda_header">Agenda</string>
|
||||
<string name="settings_agenda_range">Rozsah agendy</string>
|
||||
<string name="settings_agenda_range_hint">Jak daleko do budoucna zobrazuje obrazovka „Agenda“ události.</string>
|
||||
<string name="settings_agenda_widget_range">Rozsah widgetu agendy</string>
|
||||
<string name="settings_agenda_widget_range_hint">Jak daleko dopředu widget agendy na domovské obrazovce zobrazuje události.</string>
|
||||
<string name="settings_widget_size">Velikost widgetu agendy</string>
|
||||
<string name="settings_widget_size_hint">Velikost textu zobrazeného ve widgetu „Agenda“ na domovské obrazovce. Widget „Měsíc“ nemá žádné nastavení – jeho mřížka se vždy přizpůsobí prostoru, který mu přidělíte.</string>
|
||||
<string name="settings_widget_size_small">Malý</string>
|
||||
<string name="settings_widget_size_medium">Střední</string>
|
||||
<string name="settings_widget_size_large">Velký</string>
|
||||
<string name="settings_widget_size_extra_large">Největší</string>
|
||||
<string name="settings_widget_size_summary">Text události %1$d</string>
|
||||
<string name="settings_agenda_show_today">Vždy zobrazovat dnešek</string>
|
||||
<string name="settings_agenda_show_today_hint">Ponechat dnešní den na prvním místě agendy a jejího widgetu, třebaže již nezbývá žádná nadcházející událost.</string>
|
||||
<string name="state_failure_all_hidden">Všechny vaše kalendáře jsou vypnuté.</string>
|
||||
<string name="state_failure_all_hidden_action">Správa kalendářů</string>
|
||||
<string name="field_copy_action">Kopírovat</string>
|
||||
<string name="field_copied">Zkopírováno do schránky</string>
|
||||
<string name="field_copy_failed">Toto se nepodařilo zkopírovat</string>
|
||||
<string name="event_detail_title">Název</string>
|
||||
<string name="agenda_range_day">Dnes</string>
|
||||
<string name="agenda_range_this_week">Tento týden</string>
|
||||
<string name="agenda_range_this_month">Tento měsíc</string>
|
||||
<string name="agenda_range_week">Následujících 7 dní</string>
|
||||
<string name="agenda_range_month">Následujících 30 dní</string>
|
||||
<string name="agenda_range_custom">Vlastní…</string>
|
||||
<string name="agenda_range_custom_hint">Dny</string>
|
||||
<string name="agenda_range_override_hint">Pouze dočasně – při opětovném spuštění aplikace Calendula se nastavení vrátí k uloženému rozsahu.</string>
|
||||
<string name="settings_agenda_range_bar">Lišta s rozmezím</string>
|
||||
<string name="settings_agenda_range_bar_hint">V horní části agendy se zobrazí lišta s rozsahem dat a tlačítkem pro změnu rozsahu dané relace</string>
|
||||
<string name="agenda_range_showing_label">Zobrazit všechny nadcházející události pro</string>
|
||||
<plurals name="agenda_range_days">
|
||||
<item quantity="one">%d den</item>
|
||||
<item quantity="few">%d dny</item>
|
||||
<item quantity="many">%d dnů</item>
|
||||
<item quantity="other">%d dnů</item>
|
||||
</plurals>
|
||||
<string name="settings_section_views">Zobrazení</string>
|
||||
<string name="settings_month_header">Měsíční zobrazení</string>
|
||||
<string name="settings_month_view_style">Styl měsíčního zobrazení</string>
|
||||
<string name="month_style_paged">Stránky</string>
|
||||
<string name="month_style_paged_summary">Na obrazovce se zobrazuje jeden měsíc. Přejetím prstem doleva nebo doprava můžete změnit měsíc.</string>
|
||||
<string name="month_style_continuous">Posouvání měsíců</string>
|
||||
<string name="month_style_continuous_summary">Každý měsíc je zobrazen pod vlastním nadpisem, od následujícího je oddělen malou mezerou.</string>
|
||||
<string name="month_style_dense">Nepřerušené týdny</string>
|
||||
<string name="month_style_dense_summary">Týdny plynule navazují na sebe, jeden měsíc přechází do druhého, aniž by mezi nimi byla nějaká mezera.</string>
|
||||
<string name="month_style_split_summary">Kompaktní mřížka označuje dny, ve kterých se konají události, a den, na který klepnete, je zobrazen pod ní.</string>
|
||||
<string name="month_style_split">Rozdělení</string>
|
||||
<string name="month_split_no_events">Není nic naplánováno</string>
|
||||
<string name="month_split_expand">Zobrazit celý měsíc</string>
|
||||
<string name="month_split_collapse">Zobrazit události dne</string>
|
||||
<string name="settings_quick_switch_header">Tlačítko pro rychlé přepnutí</string>
|
||||
<string name="settings_quick_switch_hint">Vyberte, která zobrazení bude přepínat tlačítko v pravém horním rohu, a přetažením je seřaďte. Pokud jsou zapnutá méně než dvě zobrazení, tlačítko se skryje. Vypnutá zobrazení zůstanou přístupná z navigačního menu.</string>
|
||||
<string name="settings_drawer_order_header">Navigační menu</string>
|
||||
<string name="settings_drawer_order_hint">Přetažením změníte pořadí zobrazení uvedených v navigačním menu.</string>
|
||||
<string name="reorder_drag_handle">Přetažením změňte pořadí</string>
|
||||
<string name="settings_section_event_form">Formulář nové události</string>
|
||||
<string name="settings_form_fields_hint">Pole zobrazená ve výchozím nastavení — vše ostatní se zobrazí kliknutím na „Další pole“</string>
|
||||
<string name="settings_autofocus_title">Zaměření názvu při vytváření nové události</string>
|
||||
<string name="settings_autofocus_title_hint">Při vytváření nové události se kurzor umístí do pole názvu, otevře se klávesnice.</string>
|
||||
<string name="settings_event_duration">Výchozí doba trvání</string>
|
||||
<string name="settings_event_duration_hint">Jak dlouho nová událost trvá, dokud nezměníte čas jejího ukončení. Celodenní události tím nejsou ovlivněny.</string>
|
||||
<string name="settings_calendar_durations_title">Doba trvání podle kalendáře</string>
|
||||
<string name="settings_calendar_durations_hint">Nastavte kalendáři vlastní výchozí délku – například 8 hodin pro pracovní směny.</string>
|
||||
<string name="settings_calendar_duration_inherits">Výchozí (%1$s)</string>
|
||||
<string name="settings_calendar_duration_use_default">Použít výchozí dobu trvání (%1$s)</string>
|
||||
<string name="settings_color_unsupported">Povolit barvy u nepodporovaných kalendářů</string>
|
||||
<string name="settings_color_unsupported_hint">Jisté kalendáře (např. některé typu CalDAV) neposkytují vlastní definici barev; barva události tak může být při příští synchronizaci ztracena nebo přepsána. Jde o omezení daných kalendářů, nikoli o problém Calenduly.</string>
|
||||
<string name="settings_section_notifications">Oznámení</string>
|
||||
<string name="settings_reminders">Připomínky událostí</string>
|
||||
<string name="settings_reminders_hint">Vidíte připomínky dvakrát? Zobrazuje je i jiná aplikace kalendáře – v jedné z nich je vypněte.</string>
|
||||
<string name="settings_default_reminder">Výchozí připomínka</string>
|
||||
<string name="settings_default_reminder_allday">Celodenní události</string>
|
||||
<string name="settings_allday_reminder_time">Čas celodenní připomínky</string>
|
||||
<string name="settings_allday_reminder_time_hint">Připomínky na celodenní události se spouštějí v %1$s</string>
|
||||
<string name="settings_allday_reminder_fires_at">Upozorní v %1$s</string>
|
||||
<string name="reminder_none">Žádná</string>
|
||||
<string name="reminder_use_default">Použít výchozí připomínku</string>
|
||||
<string name="reminder_custom_amount">Množství</string>
|
||||
<string name="reminder_custom_set">Nastavit</string>
|
||||
<string name="settings_calendar_reminders_title">Připomínky pro jednotlivé kalendáře</string>
|
||||
<string name="settings_calendar_reminders_hint">Přepíše výchozí nastavení pro jednotlivé kalendáře – zvlášť pro události s konkrétním časem a pro celodenní události. Kalendář může zachovat výchozí nastavení, zrušit jej nebo nastavit vlastní.</string>
|
||||
<string name="settings_calendar_reminder_inherits">Výchozí (%1$s)</string>
|
||||
<string name="settings_reliable_delivery_hint">Android může kvůli úspoře baterie odložit připomínky. Udělte aplikaci Calendula výjimku, aby se zobrazovaly bez prodlení.</string>
|
||||
<string name="settings_reliable_delivery_exempt">Výloučení z optimalizace baterie – připomínky chodí bez prodlení.</string>
|
||||
<string name="settings_notifications_subtitle">Připomínky a doručení</string>
|
||||
<string name="settings_special_dates_reminders">Připomínky</string>
|
||||
<string name="settings_reliable_delivery">Spolehlivé doručení</string>
|
||||
<string name="settings_snooze_duration">Doba odložení</string>
|
||||
<string name="settings_section_calendars">Kalendáře</string>
|
||||
<string name="settings_manage_calendars">Správa kalendářů</string>
|
||||
<string name="settings_manage_calendars_hint">Vytvářet místní kalendáře; spravovat synchronizované kalendáře</string>
|
||||
<string name="settings_section_language">Jazyk</string>
|
||||
<string name="settings_language">Jazyk aplikace</string>
|
||||
<string name="settings_language_auto">Výchozí nastavení systému</string>
|
||||
<string name="settings_translate">Pomozte s překladem</string>
|
||||
<string name="settings_translate_hint">Přidat nebo vylepšit jazyk na Weblate</string>
|
||||
<string name="settings_group_look">Vzhled a chování</string>
|
||||
<string name="settings_group_data">Data</string>
|
||||
<string name="settings_group_app">Aplikace</string>
|
||||
<string name="settings_group_about">O aplikaci</string>
|
||||
<string name="settings_appearance_subtitle">Motiv, barvy, písma</string>
|
||||
<string name="settings_views_subtitle">Výchozí zobrazení, rozložení a pořadí</string>
|
||||
<string name="settings_event_form_subtitle">Výchozí pole a chování</string>
|
||||
<string name="settings_special_dates_subtitle">Narozeniny a výročí kontaktů</string>
|
||||
<string name="settings_widgets_subtitle">Widget s agendou, dlaždice rychlého nastavení</string>
|
||||
<string name="settings_backup_subtitle">Export, import, automatické zálohování</string>
|
||||
<string name="settings_section_widgets">Widgety a dlaždice</string>
|
||||
<string name="settings_widgets_hint">Nastavení widgetů na domovské obrazovce a dlaždice rychlého nastavení. Widget přidáte dlouhým stisknutím domovské obrazovky.</string>
|
||||
<string name="settings_section_backup">Záloha a obnovení</string>
|
||||
<string name="settings_views_all_header">Všechny pohledy</string>
|
||||
<string name="settings_week_day_header">Týden a den</string>
|
||||
<string name="settings_default_view_hint">Pohled, který se otevře při spuštění aplikace.</string>
|
||||
<string name="settings_week_start_hint">Počáteční den týdne ve všech zobrazeních a widgetech.</string>
|
||||
<string name="settings_time_format_hint">Způsob zápisu času v celé aplikaci. Funkce „Automaticky“ se řídí nastavením vašeho systému.</string>
|
||||
<string name="settings_past_events_hint">Jak program zachází s událostmi, které již skončily.</string>
|
||||
<string name="settings_dynamic_color_summary">Aplikace převezme barvy z vaší tapety.</string>
|
||||
<string name="settings_section_special_dates">Speciální data kontaktů</string>
|
||||
<string name="settings_special_dates_enable">Zobrazit data kontaktů</string>
|
||||
<string name="settings_special_dates_enable_hint">Zkopírujte data narozenin a další data svých kontaktů do místních kalendářů. Aplikace čte pouze kontakty v tomto zařízení – nikam se nic nenahrává, vaše kontakty zůstávají beze změny.</string>
|
||||
<string name="settings_special_dates_type_birthday">Narozeniny</string>
|
||||
<string name="settings_special_dates_type_anniversary">Výročí</string>
|
||||
<string name="settings_special_dates_type_custom">Ostatní data</string>
|
||||
<string name="settings_special_dates_template">Formát názvu</string>
|
||||
<string name="settings_special_dates_template_hint">Pro kontakt použijte {name} a pro rok {year} (rok narození nebo rok zahájení výročí; pokud není znám, údaj se skryje).</string>
|
||||
<string name="settings_special_dates_show_year">Zobrazit rok</string>
|
||||
<string name="settings_special_dates_show_year_hint">Uvést {year} v názvu, je-li znám</string>
|
||||
<string name="settings_special_dates_sync_now">Synchronizovat nyní</string>
|
||||
<string name="settings_special_dates_never_synced">Zatím nesynchronizováno</string>
|
||||
<string name="settings_special_dates_last_synced">Naposledy synchronizováno %1$s</string>
|
||||
<string name="settings_special_dates_calendar_hint">Barvu a viditelnost jednotlivých kalendářů nastavte v nastavení kalendářů.</string>
|
||||
<string name="settings_calendar_reminders_managed_hint">Nastavte v části Speciální data kontaktu</string>
|
||||
<string name="settings_special_dates_paused_title">Pozastaveno</string>
|
||||
<string name="settings_special_dates_paused_hint">Calendula už nemá přístup k vašim kontaktům, tudíž zůstávají bez aktualizace.</string>
|
||||
<string name="settings_special_dates_grant">Udělit přístup</string>
|
||||
<string name="settings_special_dates_disable_title">Vypnout data kontaktů?</string>
|
||||
<string name="settings_special_dates_disable_type_message">Tímto se odstraní kalendář „%1$s“ a jeho události. Veškeré připomínky a poznámky, které jste do nich přidali, budou ztraceny.</string>
|
||||
<string name="settings_special_dates_disable_confirm">Vypnout</string>
|
||||
<string name="dialog_save">Uložit</string>
|
||||
<string name="settings_section_about">O aplikaci</string>
|
||||
<string name="settings_license">Licence</string>
|
||||
<string name="settings_license_value">MIT</string>
|
||||
<string name="settings_about_author">od Jean-Luca Makioly</string>
|
||||
<string name="settings_about_source">Zdroj</string>
|
||||
<string name="settings_licences">Licence open source</string>
|
||||
<string name="settings_licences_subtitle">Projekty, na kterých je Calendula postavena</string>
|
||||
<string name="settings_about_privacy">Zásady ochrany osobních údajů</string>
|
||||
<string name="settings_about_support">Podpořte vývoj</string>
|
||||
<string name="settings_about_version">Verze %1$s</string>
|
||||
<string name="settings_about_logo_desc">Ikona aplikace</string>
|
||||
<string name="settings_report_problem">Nahlásit problém</string>
|
||||
<string name="settings_report_problem_hint">Odeslat hlášení o pádu nebo otevřít systém pro sledování chyb</string>
|
||||
<string name="licences_title">Licence open source</string>
|
||||
<string name="licences_intro">Calendula zahrnuje níže uvedené projekty. Klepnutím na libovolnou položku zobrazíte jeho zdrojový kód.</string>
|
||||
<string name="licences_footer">Každý projekt je zde zahrnut v nezměněné podobě a pod uvedenou licencí.</string>
|
||||
<string name="calendars_title">Kalendáře</string>
|
||||
<string name="calendars_local_header">Vaše kalendáře</string>
|
||||
<string name="calendars_local_empty">Zatím nejsou k dispozici žádné místní kalendáře. Vytvořte si kalendář, chcete-li události ukládat pouze do tohoto zařízení.</string>
|
||||
<string name="calendars_add">Přidat kalendář</string>
|
||||
<string name="calendars_visibility_hint">Vypnutím kalendáře jej na tomto zařízení skryjete – jeho události zmizí z aplikace a přestanou se zobrazovat připomínky. Jde o stejný přepínač, jaký používají i ostatní aplikace kalendáře, takže se kalendář skryje i v nich. Nic se nemaže, další zařízení to neovlivní a kalendář můžete kdykoli znovu zapnout.</string>
|
||||
<string name="calendars_visibility_a11y">Zobrazit „%1$s“</string>
|
||||
<string name="calendars_visibility_notice_title">Některé kalendáře jsou vypnuté</string>
|
||||
<string name="calendars_visibility_notice_message">Calendula zobrazuje kalendáře, které jsou pro toto zařízení zapnuté; některé z vašich kalendářů jsou však vypnuté. Chcete-li zobrazit jejich události, zapněte je v nabídce Nastavení → Kalendáře.</string>
|
||||
<string name="calendar_picker_missing_title">Chybí vám kalendář?</string>
|
||||
<string name="calendar_picker_missing_summary">Může být vypnutý, nastavený na režim „pouze pro čtení“ nebo naplněný daty z vašich kontaktů – své kalendáře spravujte zde.</string>
|
||||
<string name="calendars_state_read_only">Pouze pro čtení</string>
|
||||
<string name="calendars_state_not_synced">Není synchronizováno s tímto zařízením</string>
|
||||
<string name="calendars_state_managed">Vyplněno z vašich kontaktů</string>
|
||||
<string name="calendars_managed_delete_locked">Tento kalendář se plní z vašich kontaktů, takže jej aplikace Calendula při příští synchronizaci znovu vytvoří. Chcete-li jej odstranit, vypněte možnost speciálních dat v nabídce Nastavení → Speciální data.</string>
|
||||
<string name="calendars_synced_header">Synchronizované kalendáře</string>
|
||||
<string name="calendars_synced_hint">Tyto kontakty pocházejí z účtů ve vašem zařízení. Vytvářejte je a upravujte v příslušné aplikaci.</string>
|
||||
<string name="calendars_manage_in_app">Spravovat v aplikaci</string>
|
||||
<string name="calendars_account_from_source">%1$s (%2$s)</string>
|
||||
<string name="calendars_account_menu_a11y">Více možností pro %1$s</string>
|
||||
<string name="calendars_enable_all">Povolit vše</string>
|
||||
<string name="calendars_disable_all">Zakázat vše</string>
|
||||
<string name="calendars_add_account">Přidat účet</string>
|
||||
<string name="calendars_new_title">Nový kalendář</string>
|
||||
<string name="calendars_edit_title">Upravit kalendář</string>
|
||||
<string name="calendars_name_label">Jméno</string>
|
||||
<string name="calendars_color_label">Barva</string>
|
||||
<string name="calendars_description_hint">Přidat popis</string>
|
||||
<string name="calendars_delete_confirm_title">Vymazat kalendář?</string>
|
||||
<string name="calendars_delete_confirm_message">„%1$s“ a všechny jeho události budou z tohoto zařízení trvale odstraněny.</string>
|
||||
<string name="calendars_write_error">Změnu se nepodařilo uložit.</string>
|
||||
<string name="calendars_backup_header">Záloha</string>
|
||||
<string name="calendars_backup_hint">Místní kalendáře nejsou nikde synchronizovány, proto je pro uchování kopie exportujte do souboru ve formátu .ics.</string>
|
||||
<string name="calendars_backup_action">Exportovat jako soubor .ics</string>
|
||||
<string name="calendars_export_title">Exportovat kalendáře</string>
|
||||
<string name="calendars_export_hint">Vyberte, které kalendáře chcete zahrnout do souboru .ics.</string>
|
||||
<string name="calendars_export_action">Exportovat</string>
|
||||
<string name="calendars_restore_header">Obnovit</string>
|
||||
<string name="calendars_restore_action">Obnovit ze souboru .ics</string>
|
||||
<string name="calendars_restore_hint">Importuovat události ze zálohy nebo z jiné aplikace kalendáře.</string>
|
||||
<string name="calendars_auto_backup">Automatické zálohování</string>
|
||||
<string name="calendars_auto_backup_hint">Pravidelně exportujte své místní kalendáře do složky jako soubor .ics.</string>
|
||||
<string name="calendars_auto_backup_folder">Složka pro zálohování</string>
|
||||
<string name="calendars_auto_backup_folder_unset">Klepnutím vyberte složku</string>
|
||||
<string name="calendars_auto_backup_interval">Interval</string>
|
||||
<string name="calendars_auto_backup_every">Každý %1$s</string>
|
||||
<string name="calendars_auto_backup_interval_min">Minimálně 30 minut.</string>
|
||||
<string name="calendars_auto_backup_status_never">Zatím není nastaveno žádné automatické zálohování</string>
|
||||
<string name="calendars_auto_backup_status_ok">Poslední záloha: %1$s</string>
|
||||
<string name="calendars_auto_backup_status_failed">Poslední zálohování selhalo: %1$s</string>
|
||||
<string name="backup_channel_name">Záloha</string>
|
||||
<string name="backup_channel_description">Upozorní, pokud automatické zálohování opakovaně selže.</string>
|
||||
<string name="backup_failed_title">Automatické zálohování selhalo</string>
|
||||
<string name="backup_failed_text">Calendula nemohla zapsat záložní soubor. Zkontrolujte složku pro zálohování v nastavení.</string>
|
||||
<string name="calendars_backup_failed">Zálohu se nepodařilo exportovat.</string>
|
||||
<plurals name="calendars_backup_done">
|
||||
<item quantity="one">%d událost exportována.</item>
|
||||
<item quantity="few">%d události exportovány.</item>
|
||||
<item quantity="many">%d událostí exportováno.</item>
|
||||
<item quantity="other">%d událostí exportováno.</item>
|
||||
</plurals>
|
||||
<string name="import_title">Importovat události</string>
|
||||
<string name="import_target_header">Přidat do kalendáře</string>
|
||||
<string name="import_empty">V souboru nenalezeny žádné události.</string>
|
||||
<string name="import_failed">Tento soubor se nepodařilo přečíst.</string>
|
||||
<string name="import_no_calendar">Neexistuje žádný kalendář s právem zápisu, do kterého by bylo možné importovat. Nejprve vytvořte místní kalendář.</string>
|
||||
<string name="import_done_title">Import dokončen</string>
|
||||
<string name="import_done_dedup_note">Události, které již byly v kalendáři, byly přeskočeny.</string>
|
||||
<string name="import_done_added_label">Přidáno</string>
|
||||
<string name="import_done_skipped_label">Duplicity</string>
|
||||
<string name="import_done_failed_label">Nepřidáno</string>
|
||||
<string name="import_done_failed_note">Některé události se nepodařilo přidat, a proto byly vynechány.</string>
|
||||
<string name="import_done_stopped_title">Import byl zastaven</string>
|
||||
<string name="import_close">Zavřít</string>
|
||||
<string name="import_warning_recurrence">Některé změněné výskyty opakujících se událostí byly přeskočeny.</string>
|
||||
<string name="import_warning_no_start">Událost bez času zahájení byla přeskočena.</string>
|
||||
<string name="import_warning_attendees">Seznamy hostů nebyly importovány.</string>
|
||||
<string name="import_warning_timezone">Neznámé časové pásmo se přepnulo na časové pásmo vašeho zařízení.</string>
|
||||
<string name="import_warning_tasks">Úkoly v tomto souboru byly přidány jako události.</string>
|
||||
<string name="import_warning_recurrence_repaired">Chybné pravidlo pro opakování bylo opraveno.</string>
|
||||
<string name="import_button">Importovat</string>
|
||||
<plurals name="import_title_count">
|
||||
<item quantity="one">Importována %d událost</item>
|
||||
<item quantity="few">Importovány %d události</item>
|
||||
<item quantity="many">Importováno %d událostí</item>
|
||||
<item quantity="other">Importováno %d událostí</item>
|
||||
</plurals>
|
||||
<plurals name="import_event_count">
|
||||
<item quantity="one">V tomto souboru je %d událost.</item>
|
||||
<item quantity="few">V tomto souboru jsou %d události.</item>
|
||||
<item quantity="many">V tomto souboru je %d událostí.</item>
|
||||
<item quantity="other">V tomto souboru je %d událostí.</item>
|
||||
</plurals>
|
||||
<plurals name="import_action">
|
||||
<item quantity="one">Importovat %d událost</item>
|
||||
<item quantity="few">Importovat %d události</item>
|
||||
<item quantity="many">Importovat %d událostí</item>
|
||||
<item quantity="other">Importovat %d událostí</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_stopped">
|
||||
<item quantity="one">Kalendář přestal v průběhu procesu zpracovávat události. %d událost ze souboru nebyla přidána.</item>
|
||||
<item quantity="few">Kalendář přestal v průběhu procesu zpracovávat události. %d události ze souboru nebyly přidány.</item>
|
||||
<item quantity="many">Kalendář přestal v průběhu procesu zpracovávat události. %d událostí ze souboru nebylo přidáno.</item>
|
||||
<item quantity="other">Kalendář přestal v průběhu procesu zpracovávat události. %d událostí ze souboru nebylo přidáno.</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_imported">
|
||||
<item quantity="one">%d událost importována.</item>
|
||||
<item quantity="few">%d události importovány.</item>
|
||||
<item quantity="many">%d událostí importováno.</item>
|
||||
<item quantity="other">%d událostí importováno.</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_skipped">
|
||||
<item quantity="one">Přeskočena %d událost, která již v tomto kalendáři existuje.</item>
|
||||
<item quantity="few">Přeskočeny %d události, která již v tomto kalendáři existují.</item>
|
||||
<item quantity="many">Přeskočeno %d událostí, která již v tomto kalendáři existují.</item>
|
||||
<item quantity="other">Přeskočeno %d událostí, která již v tomto kalendáři existují.</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_failed">
|
||||
<item quantity="one">%d událost nebylo možné přidat.</item>
|
||||
<item quantity="few">%d události nebylo možné přidat.</item>
|
||||
<item quantity="many">%d událostí nebylo možné přidat.</item>
|
||||
<item quantity="other">%d událostí nebylo možné přidat.</item>
|
||||
</plurals>
|
||||
<string name="shortcut_new_event_short">Nová událost</string>
|
||||
<string name="shortcut_new_event_long">Vytvořit novou událost</string>
|
||||
<string name="qs_tile_new_event_label">Nová událost</string>
|
||||
<string name="settings_qs_tile">Přidat dlaždici rychlého nastavení</string>
|
||||
<string name="settings_qs_tile_hint">Přidat dlaždici „Nová událost“ do panelu rychlého nastavení.</string>
|
||||
<string name="crash_dialog_title">%1$s selhalo</string>
|
||||
<string name="crash_dialog_message">%1$s se minule neočekávaně ukončilo. Můžete pomoci problém vyřešit odesláním tohoto hlášení. Hlášení zůstane ve vašem zařízení, dokud se jej nerozhodnete odeslat, a neobsahuje žádné osobní údaje ani obsah kalendáře – pouze níže uvedené technické detaily.</string>
|
||||
<string name="crash_dialog_report">Odeslat hlášení o selhání</string>
|
||||
<string name="crash_dialog_dismiss">Později</string>
|
||||
<string name="crash_report_clip_label">Hlášení o selhání %1$s</string>
|
||||
<string name="crash_report_copied">Hlášení bylo zkopírováno do schránky</string>
|
||||
<string name="crash_report_open_failed">Nepodařilo se otevřít systém pro hlášení problémů. Zpráva je zkopírována ve vaší schránce.</string>
|
||||
<string name="special_dates_calendar_birthday">Narozeniny</string>
|
||||
<string name="special_dates_calendar_anniversary">Výročí</string>
|
||||
<string name="special_dates_calendar_custom">Speciální data</string>
|
||||
<string name="special_dates_default_title_birthday">Narozeniny má {name} ({year})</string>
|
||||
<string name="special_dates_default_title_anniversary">Výročí má {name} ({year})</string>
|
||||
<string name="special_dates_default_title_custom">{name}</string>
|
||||
</resources>
|
||||
|
||||
@@ -382,12 +382,9 @@
|
||||
<string name="crash_dialog_message">%1$s wurde beim letzten Mal unerwartet beendet. Du kannst bei der Behebung helfen, indem du diesen Bericht als Issue sendest. Er bleibt auf deinem Gerät, bis du ihn teilst, und enthält keine persönlichen Daten oder Kalenderinhalte — nur die technischen Angaben unten.</string>
|
||||
<string name="crash_dialog_report">Melden</string>
|
||||
<string name="crash_dialog_dismiss">Nicht jetzt</string>
|
||||
<string name="crash_report_issue_title">Absturzbericht</string>
|
||||
<string name="crash_report_clip_label">%1$s-Absturzbericht</string>
|
||||
<string name="crash_report_copied">Bericht in die Zwischenablage kopiert</string>
|
||||
<string name="crash_report_open_failed">Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage.</string>
|
||||
<string name="crash_report_body_template">Danke, dass du einen Absturz in %1$s meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%2$s\n</string>
|
||||
<string name="crash_report_body_paste">_(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_</string>
|
||||
<string name="event_edit_managed_hint">Verwaltet durch “%1$s” — der Titel, das Datum und Wiederholungen werden synchron mit deinen Kontakten gehalten. Erinnerungen, Ort und Beschreibung kannst du frei bearbeiten.</string>
|
||||
<plurals name="duration_days">
|
||||
<item quantity="one">%d Tag</item>
|
||||
|
||||
@@ -405,12 +405,9 @@
|
||||
<string name="crash_dialog_message">%1$s se cerro de forma inesperada la ultima vez. Puedes ayudar a solucionarlo enviando un reporte del problema. Este permanece en tu dispositivo hasta que decidas enviarlo, y no contiene información personal o contenido de tu calendario — solo los detalles técnicos de mas abajo.</string>
|
||||
<string name="crash_dialog_report">Reportar</string>
|
||||
<string name="crash_dialog_dismiss">No por ahora</string>
|
||||
<string name="crash_report_issue_title">Reporte de error</string>
|
||||
<string name="crash_report_clip_label">Reporte de error de %1$s</string>
|
||||
<string name="crash_report_copied">Reporte copiado a tu porta papeles</string>
|
||||
<string name="crash_report_open_failed">No se pudo abrir el sistema de seguimiento de errores. El reporte es en tu portapapeles.</string>
|
||||
<string name="crash_report_body_template">Gracias por reportar un error en %1$s. Por favor añade todo lo que recuerdas sobre lo que estabas haciendo y luego publicalo.\n\n### que sucedió\n\n\n### reporte de error\n%2$s\n</string>
|
||||
<string name="crash_report_body_paste">_(el reporte es demasiado largo para este link — pegalo aquí desde su portapeles.)_</string>
|
||||
<string name="event_attendee_resource">Recurso</string>
|
||||
<string name="settings_section_about">Acerca de</string>
|
||||
<string name="recurrence_with_count">%1$s, %2$d veces</string>
|
||||
|
||||
@@ -323,6 +323,7 @@
|
||||
<plurals name="agenda_range_days">
|
||||
<item quantity="one">%d jour</item>
|
||||
<item quantity="many">%d jours</item>
|
||||
<item quantity="other">%d jours</item>
|
||||
</plurals>
|
||||
<string name="settings_section_views">Vues</string>
|
||||
<string name="settings_quick_switch_header">Bouton d’interrupteur rapide</string>
|
||||
@@ -486,12 +487,9 @@
|
||||
<string name="crash_dialog_message">%1$s a été fermé de manière inattendue la dernière fois. Vous pouvez aider à le corriger en envoyant ce rapport en tant que problème. Il reste sur votre appareil jusqu’à ce que vous choisissiez de le partager, et n’inclut aucune donnée personnelle ni contenu de calendrier — seuls les détails techniques ci-dessous.</string>
|
||||
<string name="crash_dialog_report">rapport</string>
|
||||
<string name="crash_dialog_dismiss">pas maintenant</string>
|
||||
<string name="crash_report_issue_title">rapport de plantage</string>
|
||||
<string name="crash_report_clip_label">%1$s rapport de plantage</string>
|
||||
<string name="crash_report_copied">Rapport copié dans votre presse-papiers</string>
|
||||
<string name="crash_report_open_failed">Impossible d’ouvrir le suivi des problèmes. Le rapport se trouve dans votre presse-papiers.</string>
|
||||
<string name="crash_report_body_template">Merci d’avoir signalé un plantage dans %1$s. Veuillez ajouter tout ce dont vous vous souvenez sur ce que vous faisiez, puis soumettez-le.\n\n### Que s’est-il passé\n\n\n### Rapport de plantage\n%2$s\n</string>
|
||||
<string name="crash_report_body_paste">_(Le rapport était trop long pour ce lien — collez-le depuis votre presse-papiers ici.)_</string>
|
||||
<string name="special_dates_calendar_birthday">anniversaires</string>
|
||||
<string name="special_dates_calendar_anniversary">Anniversaires</string>
|
||||
<string name="special_dates_calendar_custom">dates spéciales</string>
|
||||
|
||||
@@ -394,12 +394,9 @@
|
||||
<string name="crash_dialog_message">%1$s si è chiuso inaspettatamente l\'ultima volta. Puoi contribuire allo sviluppo inviando questo report, che rimane sul tuo dispositivo finchè non scegli di inviarlo e non include informazioni personali o sui tuoi calendari: solo le informazioni tecniche qui sotto.</string>
|
||||
<string name="crash_dialog_report">Report</string>
|
||||
<string name="crash_dialog_dismiss">Non ora</string>
|
||||
<string name="crash_report_issue_title">Crash report</string>
|
||||
<string name="crash_report_clip_label">%1$s crash report</string>
|
||||
<string name="crash_report_copied">Report copiato negli Appunti</string>
|
||||
<string name="crash_report_open_failed">Impossibile aprire il gestore dei problemi. Il report è negli Appunti.</string>
|
||||
<string name="crash_report_body_template">Grazie per segnalare un crash di %1$s. Per favore, aggiungi ogni informazione che ricordi in merito a quello che stavi facendo, quindi invia\n\n### Cosa è successo\n\n\n###Crash report\n%2$s\n</string>
|
||||
<string name="crash_report_body_paste">_(Il report era troppo lungo per questo link - copialo qui dai tuoi Appunti.)_</string>
|
||||
<string name="event_detail_recurrence">Ricorrenza</string>
|
||||
<string name="event_detail_recurring">Evento ricorrente</string>
|
||||
<string name="recurrence_on_days">%1$s da %2$s</string>
|
||||
@@ -646,4 +643,18 @@
|
||||
<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>
|
||||
<string name="state_failure_all_hidden_action">Gestisci calendari</string>
|
||||
<string name="field_copy_action">Copia</string>
|
||||
<string name="field_copied">Copiato negli appunti</string>
|
||||
<string name="field_copy_failed">Impossibile copiarlo</string>
|
||||
<string name="event_detail_title">Titolo</string>
|
||||
<string name="reminder_after">%1$s dopo</string>
|
||||
<string name="licences_title">Licenza open source</string>
|
||||
<string name="licences_intro">Calendula include il codice riportato di seguito. Tocca una voce qualsiasi per visualizzarne il codice sorgente.</string>
|
||||
<string name="licences_footer">Ogni progetto qui presente è incluso senza modifiche, secondo la licenza indicata.</string>
|
||||
<string name="import_warning_tasks">Le attività contenute in questo file sono state aggiunte come eventi.</string>
|
||||
<string name="import_warning_recurrence_repaired">È stata corretta una regola di ripetizione errata.</string>
|
||||
<string name="import_done_failed_label">Non aggiunto</string>
|
||||
<string name="import_done_failed_note">Alcuni eventi non possono essere aggiunti e sono stati esclusi.</string>
|
||||
<string name="import_done_stopped_title">Importazione interrotta</string>
|
||||
</resources>
|
||||
|
||||
@@ -495,12 +495,9 @@
|
||||
<string name="crash_dialog_message">Aplikacja %1$s została nieoczekiwanie zamknięta przy ostatnim uruchomieniu. Możesz pomóc nam to naprawić, wysyłając zgłoszenie o błędzie. Pozostanie ono na Twoim urządzeniu, dopóki nie zdecydujesz się go udostępnić, i nie zawiera żadnych danych osobowych ani treści z kalendarza — jedynie poniższe szczegóły techniczne.</string>
|
||||
<string name="crash_dialog_report">Zgłoś</string>
|
||||
<string name="crash_dialog_dismiss">Nie teraz</string>
|
||||
<string name="crash_report_issue_title">Raport o awarii</string>
|
||||
<string name="crash_report_clip_label">Raport o awarii %1$s</string>
|
||||
<string name="crash_report_copied">Raport skopiowany do schowka</string>
|
||||
<string name="crash_report_open_failed">Nie udało się otworzyć systemu zgłoszeń. Raport znajduje się w Twoim schowku.</string>
|
||||
<string name="crash_report_body_template">Dziękujemy za zgłoszenie awarii w aplikacji %1$s. Prosimy o dodanie wszelkich zapamiętanych szczegółów na temat wykonywanych czynności, a następnie przesłanie zgłoszenia.\n\n### Co się wydarzyło\n\n\n### Raport o awarii\n%2$s\n</string>
|
||||
<string name="crash_report_body_paste">_(Raport był zbyt długi dla tego linku — wklej go tutaj ze schowka)._</string>
|
||||
<string name="special_dates_calendar_custom">Szczególne daty</string>
|
||||
<string name="special_dates_default_title_birthday">Urodziny {name} ({year})</string>
|
||||
<string name="special_dates_default_title_anniversary">Rocznica {name} ({year})</string>
|
||||
|
||||
@@ -636,12 +636,9 @@
|
||||
<string name="crash_dialog_message">%1$s fechou inesperadamente da última vez. Você pode ajudar a consertar enviando este reporte como um problema. Ele fica no seu dispositivo até que decida enviá-lo, e não inclui dados pessoais ou conteúdo de calendário — somente os detalhes técnicos abaixo.</string>
|
||||
<string name="crash_dialog_report">Reportar</string>
|
||||
<string name="crash_dialog_dismiss">Agora não</string>
|
||||
<string name="crash_report_issue_title">Reportar quebra</string>
|
||||
<string name="crash_report_clip_label">%1$s relatório de quebra</string>
|
||||
<string name="crash_report_copied">Relatório copiado para a área de transferência</string>
|
||||
<string name="crash_report_open_failed">Não foi possível abrir o tracker de problemas. O relatório está na área de transferência.</string>
|
||||
<string name="crash_report_body_template">Obrigado por reportar uma quebra em %1$s. Por favor, escreva o que estava fazendo, depois envie. \n### O que aconteceu \n\n\n### Relatório de quebra \n%2$s\n</string>
|
||||
<string name="crash_report_body_paste">_(O reporte era muito longo para este link — cole da área de transferência aqui.)_</string>
|
||||
<string name="special_dates_calendar_birthday">Aniversários</string>
|
||||
<string name="special_dates_calendar_anniversary">Aniversários de relacionamento</string>
|
||||
<string name="special_dates_calendar_custom">Datas especiais</string>
|
||||
|
||||
@@ -6,4 +6,506 @@
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
<string name="app_name">Calendula</string>
|
||||
<string name="app_tagline">Um calendário moderno.</string>
|
||||
<string name="state_loading">A carregar…</string>
|
||||
<string name="state_retry">Tentar novamente</string>
|
||||
<string name="state_failure_unknown">Algo correu mal.</string>
|
||||
<string name="state_failure_permission">O acesso ao calendário é necessário.</string>
|
||||
<string name="state_failure_permission_action">Conceder acesso</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_failure_all_hidden">Todos os seus calendários estão desligados.</string>
|
||||
<string name="state_failure_all_hidden_action">Gerir calendários</string>
|
||||
<string name="state_failure_provider">Não foi possível ler o calendário.</string>
|
||||
<string name="field_copy_action">Copiar</string>
|
||||
<string name="field_copied">Copiado para a área de transferência</string>
|
||||
<string name="field_copy_failed">Não foi possível copiar</string>
|
||||
<string name="permission_rationale_title">Veja todos os seus eventos, lindamente</string>
|
||||
<string name="permission_rationale_body">O Calendula precisa de aceder ao seu calendário para mostrar e gerir eventos. É tudo o 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">O Calendula não consegue mostrar eventos sem acesso ao calendário. Pode concedê-lo 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">Sem permissões de rede, sem telemetria — nada sai do seu telemóvel.</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 simplesmente 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">Mês seguinte</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">Eliminar</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">Eliminar evento?</string>
|
||||
<string name="event_delete_body">O evento será removido do seu calendário e de todos os aparelhos que o sincronizam.</string>
|
||||
<string name="event_delete_recurring_title">Eliminar 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 eliminar o evento</string>
|
||||
<string name="event_delete_write_denied">O Calendula requer permissão de escrita para excluir eventos</string>
|
||||
<string name="event_move_action">Mover…</string>
|
||||
<string name="event_move_recurring_title">Mover evento recorrente</string>
|
||||
<string name="event_move_occurrence_only">Mover a série inteira mudaria os dias em que o evento cai, então somente este evento se pode mover.</string>
|
||||
<string name="event_move_done">Movido para %1$s</string>
|
||||
<string name="event_move_undo">Desfazer</string>
|
||||
<string name="event_move_undone">Mover desfeito</string>
|
||||
<string name="event_move_failed">Não foi possível mover o evento</string>
|
||||
<string name="event_move_write_denied">O Calendula precisa de permissão de escrita para mover eventos</string>
|
||||
<string name="event_move_gone">Este evento já não existe</string>
|
||||
<string name="event_move_blocked_series_end">Impossível mover um evento após o fim da sua série</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">Guardar</string>
|
||||
<string name="event_edit_title_hint">Adicionar título</string>
|
||||
<string name="event_edit_managed_hint">Gerido por “%1$s” — o título, data e frequência são sincronizados dos seus contactos. Os lembretes, os locais e as 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 guardar o evento</string>
|
||||
<string name="event_edit_write_denied">O 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 contactos</string>
|
||||
<string name="event_edit_location_from_contacts">Escolher endereço dos contactos</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">O Calendula não envia convites. A 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="event_edit_timezone_device_summary">Segue onde estiver</string>
|
||||
<string name="event_edit_timezone_search">Procurar fusos horários</string>
|
||||
<string name="event_edit_timezone_all">Todos os fusos horários</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_recent">Recente</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, no seu fuso horário</string>
|
||||
<string name="event_edit_color_default">Cor do calendário</string>
|
||||
<string name="event_edit_color">Cor</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. 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 noutro lugar</string>
|
||||
<string name="event_edit_conflict_body">Enquanto estava a editar, este evento foi alterado — por sincronia ou por outra aplicação. O que fazer com as suas mudanças?</string>
|
||||
<string name="event_edit_conflict_overwrite">Guardar mudanças</string>
|
||||
<string name="event_edit_conflict_overwrite_hint">Apenas os 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">O evento fica como está agora</string>
|
||||
<string name="event_edit_gone_title">Evento eliminado</string>
|
||||
<string name="event_edit_gone_body">Este evento foi entretanto eliminado, por exemplo, noutro aparelho. As suas mudanças já não podem ser guardadas.</string>
|
||||
<string name="import_reminder_prompt_title">Aplicar o 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 o 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">Numa 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">As 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_title">Título</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">Todos os dias</string>
|
||||
<string name="recurrence_weekly">Todas as semanas</string>
|
||||
<string name="recurrence_monthly">Todos os meses</string>
|
||||
<string name="recurrence_yearly">Todos os anos</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 à %2$s</string>
|
||||
<string name="recurrence_with_count">%1$s, %2$d vezes</string>
|
||||
<string name="recurrence_with_until">%1$s até %2$s</string>
|
||||
<string name="event_detail_not_found">Este evento já não existe.</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_tentative">Talvez</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_public_summary">Todos com acesso veem tudo</string>
|
||||
<string name="event_access_default_summary">O que este calendário normalmente faz</string>
|
||||
<string name="event_access_private_summary">Os 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">A sua resposta: %1$s</string>
|
||||
<string name="reminder_at_time">Na hora do evento</string>
|
||||
<string name="reminder_default">Lembrete padrão</string>
|
||||
<string name="reminder_after">%1$s depois</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>
|
||||
<plurals name="duration_hours">
|
||||
<item quantity="one">%d hora</item>
|
||||
<item quantity="many">%d horas</item>
|
||||
<item quantity="other">%d horas</item>
|
||||
</plurals>
|
||||
<plurals name="duration_days">
|
||||
<item quantity="one">%d dia</item>
|
||||
<item quantity="many">%d dias</item>
|
||||
<item quantity="other">%d dias</item>
|
||||
</plurals>
|
||||
<string name="duration_hours_minutes">%1$s %2$s</string>
|
||||
<string name="duration_custom_max">No máximo %1$s</string>
|
||||
<plurals name="duration_weeks">
|
||||
<item quantity="one">%d semana</item>
|
||||
<item quantity="many">%d semanas</item>
|
||||
<item quantity="other">%d semanas</item>
|
||||
</plurals>
|
||||
<string name="event_untitled">(Sem título)</string>
|
||||
<string name="onboarding_step_counter">Passo %1$d de %2$d</string>
|
||||
<string name="onboarding_backup_title">Os seus eventos vivem somente aqui</string>
|
||||
<string name="onboarding_backup_body">Nada neste aparelho sincroniza para uma conta, ou seja, se o perder perde também o seu calendário. O Calendula pode criar um backup para si.</string>
|
||||
<string name="onboarding_backup_benefit_folder_title">Uma pasta à sua escolha</string>
|
||||
<string name="onboarding_backup_benefit_folder_body">Os backups são arquivos .ics — coloque-os num lugar para sincronizar, ou num cartão SD.</string>
|
||||
<string name="onboarding_backup_benefit_daily_title">Uma vez por dia, sozinho</string>
|
||||
<string name="onboarding_backup_benefit_daily_body">O Calendula exporta os seus calendários locais em segundo plano. Mude a frequência nas Configurações.</string>
|
||||
<string name="onboarding_backup_enable_button">Escolher pasta e fazer backup</string>
|
||||
<string name="onboarding_backup_skip_button">Agora não</string>
|
||||
<string name="onboarding_view_title">O que deve abrir primeiro?</string>
|
||||
<string name="onboarding_month_style_title">Como se deve o Mês parecer?</string>
|
||||
<string name="onboarding_view_continue_button">Continuar</string>
|
||||
<string name="onboarding_back">Voltar</string>
|
||||
<string name="onboarding_visibility_button">Entendi</string>
|
||||
<string name="onboarding_done_title">Tudo pronto</string>
|
||||
<string name="onboarding_done_body">Os seus calendários estão prontos. Todas as suas escolhas podem ser alteradas depois nas Configurações.</string>
|
||||
<string name="onboarding_done_button">Abrir o meu calendário</string>
|
||||
<string name="reminder_channel_description">Notificações na hora dos lembretes dos seus eventos</string>
|
||||
<string name="reminder_onboarding_title">Nunca perca um evento</string>
|
||||
<string name="reminder_onboarding_body">O Android não mostra os seus lembretes sozinho — uma aplicação de calendário é que faz isso. Deixe o Calendula cuidar disso.</string>
|
||||
<string name="reminder_benefit_delivery_title">Lembretes, entregues</string>
|
||||
<string name="reminder_benefit_delivery_body">Todos os seus lembretes de evento chegam em notificação, na hora certa.</string>
|
||||
<string name="reminder_benefit_duplicates_title">A usar outra aplicação de calendário?</string>
|
||||
<string name="reminder_benefit_duplicates_body">Se outra aplicação também enviar lembretes, vê-los-á em dobro — desligue aqui ou lá.</string>
|
||||
<string name="reminder_benefit_reversible_body">O botão está nas Configurações, dentro de Notificações.</string>
|
||||
<string name="reminder_onboarding_enable_button">Ativar lembretes</string>
|
||||
<string name="reminder_onboarding_skip_button">Agora não</string>
|
||||
<string name="reminder_action_snooze">Soneca</string>
|
||||
<string name="reminder_action_dismiss">Ignorar</string>
|
||||
<string name="reminder_day_tomorrow">Amanhã</string>
|
||||
<string name="reminder_day_yesterday">Ontem</string>
|
||||
<string name="view_month">Mês</string>
|
||||
<string name="view_week">Semana</string>
|
||||
<string name="view_day">Dia</string>
|
||||
<string name="view_agenda">Agenda</string>
|
||||
<string name="view_section">Vista</string>
|
||||
<string name="drawer_jump_to_date">Ir para data</string>
|
||||
<string name="agenda_today_action">Hoje</string>
|
||||
<string name="agenda_header_today">Hoje</string>
|
||||
<string name="agenda_header_tomorrow">Amanhã</string>
|
||||
<string name="agenda_empty_title">Tudo em dia</string>
|
||||
<string name="agenda_no_more_today">Sem mais eventos hoje</string>
|
||||
<string name="agenda_span_starts">Começa às %1$s</string>
|
||||
<string name="agenda_span_ends">Termina às %1$s</string>
|
||||
<string name="today_jump_action">Ir para Hoje</string>
|
||||
<string name="search_action">Procurar</string>
|
||||
<string name="search_hint">Procurar eventos</string>
|
||||
<string name="search_back">Voltar</string>
|
||||
<string name="search_idle_hint">Procure os seus eventos por título, local ou notas.</string>
|
||||
<plurals name="search_selected_count">
|
||||
<item quantity="one">%d selecionado</item>
|
||||
<item quantity="many">%d selecionados</item>
|
||||
<item quantity="other">%d selecionados</item>
|
||||
</plurals>
|
||||
<string name="search_in_descriptions">Encontrado em descrições</string>
|
||||
<string name="search_selection_close">Cancelar seleção</string>
|
||||
<string name="search_select_all">Selecionar tudo</string>
|
||||
<string name="search_delete_selected">Eliminar selecionados</string>
|
||||
<plurals name="search_delete_title">
|
||||
<item quantity="one">Eliminar %d evento?</item>
|
||||
<item quantity="many">Eliminar %d eventos?</item>
|
||||
<item quantity="other">Eliminar %d eventos?</item>
|
||||
</plurals>
|
||||
<plurals name="search_delete_done">
|
||||
<item quantity="one">%d evento eliminado</item>
|
||||
<item quantity="many">%d eventos eliminados</item>
|
||||
<item quantity="other">%d eventos eliminados</item>
|
||||
</plurals>
|
||||
<string name="search_delete_partial">%1$d eliminado(s), %2$d não puderam ser eliminado(s)</string>
|
||||
<string name="search_delete_denied_partial">%1$d eliminado(s), e depois a permissão de escrita foi removida</string>
|
||||
<string name="widget_agenda_title">A seguir</string>
|
||||
<string name="widget_agenda_label">Agenda do Calendula</string>
|
||||
<string name="widget_month_label">Mês do Calendula</string>
|
||||
<string name="widget_refresh">Atualizar</string>
|
||||
<string name="widget_new_event">Novo evento</string>
|
||||
<string name="widget_needs_permission">Abra o Calendula para conceder acesso ao calendário</string>
|
||||
<string name="widget_prev_month">Mês passado</string>
|
||||
<string name="widget_next_month">Mês seguinte</string>
|
||||
<string name="widget_today">Hoje</string>
|
||||
<string name="filter_title">Calendários</string>
|
||||
<string name="settings_title">Configurações</string>
|
||||
<string name="search_empty">Nenhum evento corresponde a \"%1$s\".</string>
|
||||
<string name="search_clear">Limpar</string>
|
||||
<string name="reminder_benefit_reversible_title">Mude a qualquer hora</string>
|
||||
<string name="reminder_channel_name">Lembretes de evento</string>
|
||||
<string name="settings_back">Voltar</string>
|
||||
<string name="back">Voltar</string>
|
||||
<string name="settings_section_appearance">Aparência</string>
|
||||
<string name="settings_theme">Tema</string>
|
||||
<string name="settings_theme_system">Sistema</string>
|
||||
<string name="settings_theme_light">Claro</string>
|
||||
<string name="settings_theme_dark">Escuro</string>
|
||||
<string name="settings_theme_hint">Se a aplicação é clara ou escura. Aplica imediatamente.</string>
|
||||
<string name="settings_theme_system_summary">De momento %1$s</string>
|
||||
<string name="settings_default_view">Vista padrão</string>
|
||||
<string name="settings_dynamic_color">Cor dinâmica</string>
|
||||
<string name="settings_dynamic_color_unavailable">Requer Android 12 ou mais recente</string>
|
||||
<string name="settings_soften_colors">Harmonizar cores do calendário</string>
|
||||
<string name="settings_soften_colors_summary">Mantenha cada tom do calendário mas equilibre os brilhos, para que cada evento continue legível e as cores combinem. Desative para mostrar as cores brutas da fonte do calendário.</string>
|
||||
<string name="settings_font_headings">Fonte dos cabeçalhos</string>
|
||||
<string name="settings_font_body">Fonte do corpo</string>
|
||||
<string name="settings_font_system">Padrão do sistema</string>
|
||||
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
|
||||
<string name="font_lora">Lora</string>
|
||||
<string name="font_jetbrains_mono">JetBrains Mono</string>
|
||||
<string name="settings_font_choose_file">Escolher arquivo…</string>
|
||||
<string name="settings_font_custom_selected">Fonte personalizada</string>
|
||||
<string name="settings_font_import_failed">Não foi possível ler o ficheiro como fonte</string>
|
||||
<string name="settings_font_specimen_heading">Quinta-feira, 14 de maio</string>
|
||||
<string name="settings_font_specimen_body">Reunião de equipa às 10:00, depois almoço com a Robin no café em Markplatz.</string>
|
||||
<string name="settings_week_start">A semana começa no/na</string>
|
||||
<string name="settings_week_start_auto">Automático</string>
|
||||
<string name="settings_week_start_auto_summary">De momento %1$s</string>
|
||||
<string name="settings_week_numbers">Números das semanas</string>
|
||||
<string name="settings_week_numbers_summary">Mostrar números das semanas na vista do mês</string>
|
||||
<string name="settings_today_toolbar">Botão \"Hoje\" na barra de ferramentas</string>
|
||||
<string name="settings_today_toolbar_summary">Mostrar um botão \"Hoje\" na barra de ferramentas em vez de um botão flutuante</string>
|
||||
<string name="settings_app_name">Nome da aplicação</string>
|
||||
<string name="settings_app_name_summary">Mostrar o Calendula como “Calendário” no seu launcher. Somente o nome do launcher muda; o ícone pode mudar para outro lugar após a troca.</string>
|
||||
<string name="settings_time_format">Formato da hora</string>
|
||||
<string name="settings_time_format_auto">Automático</string>
|
||||
<string name="settings_time_format_12h">12 horas (2:00 PM)</string>
|
||||
<string name="settings_time_format_24h">24 horas (14:00)</string>
|
||||
<string name="settings_time_format_auto_summary">A seguir o sistema: %1$s</string>
|
||||
<string name="settings_hour_lines">Grelha de horas</string>
|
||||
<string name="settings_hour_lines_summary">Colocar cada hora na sua própria célula nas vistas Semana e Dia</string>
|
||||
<string name="settings_timeline_scale">Altura da hora</string>
|
||||
<string name="settings_timeline_scale_hint">Quanto espaço vertical que a hora ocupa nas vistas Semana e Dia. Ambas as vistas partilham este ajuste. Pode também pinçar a linha do tempo com dois dedos para definir outra altura.</string>
|
||||
<string name="timeline_scale_fit_day">Encaixar o dia todo</string>
|
||||
<string name="timeline_scale_fit_day_summary">24 horas no ecrã, sem scroll</string>
|
||||
<string name="timeline_scale_compact">Compacto</string>
|
||||
<string name="timeline_scale_compact_summary">Mais horas por ecrã, blocos menores</string>
|
||||
<string name="timeline_scale_regular">Normal</string>
|
||||
<string name="timeline_scale_regular_summary">Espaçamento padrão</string>
|
||||
<string name="timeline_scale_comfortable">Confortável</string>
|
||||
<string name="timeline_scale_comfortable_summary">Blocos maiores, mais scroll</string>
|
||||
<string name="timeline_scale_custom">Personalizado</string>
|
||||
<string name="timeline_scale_custom_summary">A altura em que pinçou a linha do tempo</string>
|
||||
<string name="settings_dim_completed">Esmaecer eventos passados</string>
|
||||
<string name="settings_dim_completed_summary">Esmaecer eventos que já terminaram nas vistas do mês e semana</string>
|
||||
<string name="settings_drag_to_reschedule">Arrastar para reagendar</string>
|
||||
<string name="settings_drag_to_reschedule_summary">Mova um evento arrastando-o para outro dia ou hora</string>
|
||||
<string name="settings_past_events">Eventos passados</string>
|
||||
<string name="settings_past_events_show">Mostrar</string>
|
||||
<string name="settings_past_events_dim">Esmaecer</string>
|
||||
<string name="settings_past_events_hide">Ocultar</string>
|
||||
<string name="settings_past_events_sample_morning">Revisão da equipa</string>
|
||||
<string name="settings_past_events_sample_midday">Almoço com a Robin</string>
|
||||
<string name="settings_past_events_sample_evening">Treino do coro</string>
|
||||
<string name="settings_agenda_header">Agenda</string>
|
||||
<string name="settings_agenda_range">Amplitude da agenda</string>
|
||||
<string name="settings_agenda_range_hint">O quanto à frente o ecrã da Agenda lista eventos.</string>
|
||||
<string name="settings_agenda_widget_range">Amplitude do widget de Agenda</string>
|
||||
<string name="settings_agenda_widget_range_hint">O quanto à frente o widget de Agenda lista eventos.</string>
|
||||
<string name="settings_widget_size">Tamanho do widget de Agenda</string>
|
||||
<string name="settings_widget_size_hint">Quão grande é o texto do widget de Agenda. O widget de mês não tem ajuste — a sua grelha encaixa sempre no espaço dado.</string>
|
||||
<string name="settings_widget_size_small">Pequeno</string>
|
||||
<string name="settings_widget_size_medium">Médio</string>
|
||||
<string name="settings_widget_size_large">Grande</string>
|
||||
<string name="settings_widget_size_extra_large">Extra grande</string>
|
||||
<string name="settings_widget_size_summary">Texto do evento %1$d%%</string>
|
||||
<string name="settings_agenda_show_today">Mostrar sempre Hoje</string>
|
||||
<string name="settings_agenda_show_today_hint">Manter o dia de hoje no topo da agenda e no widget, mesmo que não tenha mais nada hoje.</string>
|
||||
<string name="settings_agenda_range_bar">Barra de amplitude</string>
|
||||
<string name="settings_agenda_range_bar_hint">Mostrar uma barra no topo da agenda a nomear as datas mostradas, com um botão para mudar a amplitude durante a sessão</string>
|
||||
<string name="agenda_range_day">Hoje</string>
|
||||
<string name="agenda_range_this_week">Esta semana</string>
|
||||
<string name="agenda_range_this_month">Este mês</string>
|
||||
<string name="agenda_range_week">Nos próximos 7 dias</string>
|
||||
<string name="agenda_range_month">Nos próximos 30 dias</string>
|
||||
<string name="agenda_range_custom">Personalizado…</string>
|
||||
<string name="agenda_range_custom_hint">Dias</string>
|
||||
<string name="agenda_range_override_hint">Só por enquanto — reajusta para a amplitude guardada ao reabrir o Calendula.</string>
|
||||
<string name="agenda_range_showing_label">A mostrar todos os eventos nos próximos</string>
|
||||
<plurals name="agenda_range_days">
|
||||
<item quantity="one">%d dia</item>
|
||||
<item quantity="many">%d dias</item>
|
||||
<item quantity="other">%d dias</item>
|
||||
</plurals>
|
||||
<string name="settings_section_views">Vistas</string>
|
||||
<string name="settings_month_header">Vista do Mês</string>
|
||||
<string name="settings_month_view_style">Estilo de vista do Mês</string>
|
||||
<string name="month_style_paged">Páginas</string>
|
||||
<string name="month_style_paged_summary">Um mês ocupa o ecrã. Deslize para os lados para mudar o mês.</string>
|
||||
<string name="month_style_continuous">Meses contínuos</string>
|
||||
<string name="month_style_continuous_summary">Cada mês tem o próprio cabeçalho, com um espaço a separá-lo do próximo.</string>
|
||||
<string name="month_style_dense">Semanas integradas</string>
|
||||
<string name="month_style_dense_summary">As semanas seguem sem separação, com cada mês a terminar no início do outro sem distinção entre eles.</string>
|
||||
<string name="month_style_split">Dividido</string>
|
||||
<string name="month_style_split_summary">Uma grelha compacta marca os dias com eventos, e o dia clicado é listado abaixo.</string>
|
||||
<string name="month_split_no_events">Nada agendado</string>
|
||||
<string name="month_split_expand">Mostrar o mês inteiro</string>
|
||||
<string name="month_split_collapse">Mostrar os eventos do dia</string>
|
||||
<string name="settings_quick_switch_header">Botão de troca rápida</string>
|
||||
<string name="settings_quick_switch_hint">Escolha que vistas pelas quais o botão no canto superior direito circula, e arraste para as ordenar. Com menos de duas vistas ligadas o botão é escondido. As vistas desligadas continuam acessíveis pelo menu de navegação.</string>
|
||||
<string name="settings_drawer_order_header">Menu de navegação</string>
|
||||
<string name="settings_drawer_order_hint">Arraste para reordenar as vistas listadas no menu de navegação.</string>
|
||||
<string name="reorder_drag_handle">Arraste para reordenar</string>
|
||||
<string name="settings_section_event_form">Novo formulário de evento</string>
|
||||
<string name="settings_form_fields_hint">Campos padrão — o restante está dentro de \"Mais campos\"</string>
|
||||
<string name="settings_autofocus_title">Focar no título num novo evento</string>
|
||||
<string name="settings_autofocus_title_hint">Ao iniciar um novo evento, colocar o cursor no campo de título e abrir o teclado.</string>
|
||||
<string name="settings_event_duration">Duração padrão</string>
|
||||
<string name="settings_event_duration_hint">Quanto tempo um novo evento dura até mudar o término. Os eventos de dia inteiro não são afetados.</string>
|
||||
<string name="settings_calendar_durations_title">Duração por calendário</string>
|
||||
<string name="settings_calendar_durations_hint">Dar a um calendário a sua própria duração padrão — ex.: 8 horas para trabalho.</string>
|
||||
<string name="settings_calendar_duration_inherits">Padrão (%1$s)</string>
|
||||
<string name="settings_calendar_duration_use_default">Usar duração padrão (%1$s)</string>
|
||||
<string name="settings_color_unsupported">Permitir cores em calendários não suportados</string>
|
||||
<string name="settings_color_unsupported_hint">Alguns calendários (ex.: certos CalDAV) não publicam cores; uma cor de evento personalizada pode ser apagada ou sobrescrita na próxima sincronização. Esta é uma limitação destes calendários, e não é algo que o Calendula pode arranjar.</string>
|
||||
<string name="settings_section_notifications">Notificações</string>
|
||||
<string name="settings_reminders">Lembretes de evento</string>
|
||||
<string name="settings_reminders_hint">Vê lembretes em duplicado ? Outra aplicação de calendário está a enviá-los — desligue num dos dois.</string>
|
||||
<string name="settings_default_reminder">Lembrete padrão</string>
|
||||
<string name="settings_default_reminder_allday">Eventos de dia todo</string>
|
||||
<string name="settings_allday_reminder_time">Hora de lembrete de evento de dia todo</string>
|
||||
<string name="settings_allday_reminder_time_hint">Os lembretes para eventos de dia todo disparam às %1$s</string>
|
||||
<string name="settings_allday_reminder_fires_at">Notifica às %1$s</string>
|
||||
<string name="reminder_none">Nenhum</string>
|
||||
<string name="reminder_use_default">Usar lembrete padrão</string>
|
||||
<string name="reminder_custom_amount">Quantidade</string>
|
||||
<string name="reminder_custom_set">Ajustar</string>
|
||||
<string name="settings_calendar_reminders_title">Lembretes por calendário</string>
|
||||
<string name="settings_calendar_reminders_hint">Mude o padrão por calendário — separadamente para eventos com horário e de dia todo. Um calendário pode manter o padrão, removê-lo, ou ajustar o seu próprio.</string>
|
||||
<string name="settings_calendar_reminder_inherits">Padrão (%1$s)</string>
|
||||
<string name="settings_reliable_delivery">Entrega confiável</string>
|
||||
<string name="settings_reliable_delivery_hint">O Android pode atrasar lembretes para economizar bateria. Autorize o Calendula para que possam chegar na hora.</string>
|
||||
<string name="settings_reliable_delivery_exempt">Isentar de otimização de bateria — os lembretes chegam na hora.</string>
|
||||
<string name="settings_snooze_duration">Duração da soneca</string>
|
||||
<string name="settings_section_calendars">Calendários</string>
|
||||
<string name="settings_manage_calendars">Gerir calendários</string>
|
||||
<string name="settings_manage_calendars_hint">Crie calendários locais; gira os sincronizados</string>
|
||||
<string name="settings_section_language">Idioma</string>
|
||||
<string name="settings_language">Idioma da aplicação</string>
|
||||
<string name="settings_language_auto">Padrão do sistema</string>
|
||||
<string name="settings_translate">Ajude a traduzir</string>
|
||||
<string name="settings_translate_hint">Adicione ou melhore um idioma no Weblate</string>
|
||||
<string name="settings_group_look">Aparência e comportamento</string>
|
||||
<string name="settings_group_data">Dados</string>
|
||||
<string name="settings_group_app">Aplicação</string>
|
||||
<string name="settings_group_about">Sobre</string>
|
||||
<string name="settings_appearance_subtitle">Tema, cores, fontes</string>
|
||||
<string name="settings_views_subtitle">Vista, layout e ordem padrão</string>
|
||||
<string name="settings_event_form_subtitle">Campos e comportamento padrão</string>
|
||||
<string name="settings_notifications_subtitle">Lembretes e entrega</string>
|
||||
<string name="settings_special_dates_subtitle">Aniversários e datas de contacto</string>
|
||||
<string name="settings_widgets_subtitle">Widget de Agenda, bloco de Ajustes Rápidos</string>
|
||||
<string name="settings_backup_subtitle">Exportar, importar, backup automático</string>
|
||||
<string name="settings_section_widgets">Widgets e blocos</string>
|
||||
<string name="settings_widgets_hint">Ajustes para os widgets do ecrã inicial e para o bloco de Ajustes Rápidos. Adicione um widget pressionando e segurando o ecrã inicial.</string>
|
||||
<string name="settings_section_backup">Backup e restauro</string>
|
||||
<string name="settings_views_all_header">Todas as vistas</string>
|
||||
<string name="settings_week_day_header">Semana e dia</string>
|
||||
<string name="settings_default_view_hint">A vista que o Calendula abre ao iniciar.</string>
|
||||
<string name="settings_week_start_hint">O dia em que cada semana inicia, em todas as vistas e widgets.</string>
|
||||
<string name="settings_time_format_hint">Como as horas são escritas pela aplicação. \"Automático\" segue a configuração do sistema.</string>
|
||||
<string name="settings_past_events_hint">O que a agenda faz com eventos que já terminaram.</string>
|
||||
<string name="settings_dynamic_color_summary">Use as cores da aplicação de acordo com seu papel de parede.</string>
|
||||
<string name="settings_section_special_dates">Datas especiais de contactos</string>
|
||||
<string name="settings_special_dates_enable">Mostrar datas de contactos</string>
|
||||
<string name="settings_special_dates_enable_hint">Copiar os aniversários e outras datas dos contactos para calendários locais. Lê os contatos somente neste aparelho — nada é enviado, e seus contatos nunca são alterados.</string>
|
||||
<string name="settings_special_dates_type_birthday">Aniversários</string>
|
||||
<string name="settings_special_dates_type_anniversary">Aniversários de relacionamentos</string>
|
||||
<string name="settings_special_dates_type_custom">Outras datas</string>
|
||||
<string name="settings_special_dates_template">Formato do título</string>
|
||||
<string name="settings_special_dates_template_hint">Use {nome} para o contacto e {ano} para o ano (de nascimento, ou de início do relacionamento; oculto quando desconhecido).</string>
|
||||
</resources>
|
||||
|
||||
@@ -344,4 +344,15 @@
|
||||
<string name="settings_week_start">Неделя начинается в</string>
|
||||
<string name="settings_week_start_auto">Автоматически</string>
|
||||
<string name="settings_week_start_auto_summary">Выбрано: %1$s</string>
|
||||
<string name="state_failure_all_hidden_action">Управление календарями</string>
|
||||
<string name="field_copy_action">Копировать</string>
|
||||
<string name="field_copied">Скопировано в буфер обмена</string>
|
||||
<string name="event_detail_title">Заголовок</string>
|
||||
<string name="settings_week_numbers">Номер недели</string>
|
||||
<string name="settings_app_name">Имя приложения</string>
|
||||
<string name="settings_app_name_summary">Отображать Calendula как “Календарь” в вашем лаунчере. Изменяется только отображаемое имя в лаунчере; иконка может переместиться на новое место после включения.</string>
|
||||
<string name="settings_time_format">Формат времени</string>
|
||||
<string name="settings_time_format_auto">Автоматически</string>
|
||||
<string name="settings_time_format_12h">12-часовой формат времени</string>
|
||||
<string name="settings_time_format_24h">24-часовой формат времени</string>
|
||||
</resources>
|
||||
|
||||
@@ -263,4 +263,44 @@
|
||||
<string name="onboarding_done_button">Otvoriť môj kalendár</string>
|
||||
<string name="reminder_channel_name">Pripomienky udalostí</string>
|
||||
<string name="reminder_channel_description">Notifikácie v časoch pripomienok vašich udalostí</string>
|
||||
<string name="agenda_today_action">Dnes</string>
|
||||
<string name="agenda_header_today">Dnes</string>
|
||||
<string name="search_back">Späť</string>
|
||||
<string name="widget_new_event">Nová udalosť</string>
|
||||
<string name="widget_prev_month">Predchádzajúci mesiac</string>
|
||||
<string name="widget_next_month">Nasledujúci mesiac</string>
|
||||
<string name="widget_today">Dnes</string>
|
||||
<string name="filter_title">Kalendáre</string>
|
||||
<string name="settings_title">Nastavenia</string>
|
||||
<string name="settings_back">Späť</string>
|
||||
<string name="back">Späť</string>
|
||||
<string name="timeline_scale_custom">Vlastné</string>
|
||||
<string name="agenda_range_day">Dnes</string>
|
||||
<string name="state_failure_all_hidden">Všetky vaše kalendáre sú vypnuté.</string>
|
||||
<string name="state_failure_all_hidden_action">Správa kalendárov</string>
|
||||
<string name="field_copy_action">Kopírovať</string>
|
||||
<string name="field_copied">Skopirovane do schránky</string>
|
||||
<string name="field_copy_failed">Toto sa nepodarilo skopírovať</string>
|
||||
<string name="event_detail_title">Názov</string>
|
||||
<string name="reminder_after">%1$s po</string>
|
||||
<string name="reminder_onboarding_title">Nikdy nezmeškajte udalosť</string>
|
||||
<string name="reminder_onboarding_body">Samotný Android nezobrazuje pripomienky udalostí - to musí zabezpečiť aplikácia kalendáru. Nechajte túto prácu na Calendule.</string>
|
||||
<string name="reminder_benefit_delivery_title">Pripomienky, doručené</string>
|
||||
<string name="reminder_benefit_delivery_body">Pripomienky k vašim udalostiam vám prídu ako notifikácia, vždy a načas.</string>
|
||||
<string name="reminder_benefit_duplicates_title">Používate ďalšiu aplikáciu s kalendárom?</string>
|
||||
<string name="reminder_onboarding_skip_button">Neskôr</string>
|
||||
<plurals name="search_delete_title">
|
||||
<item quantity="one">Odstrániť %d udalosť?</item>
|
||||
<item quantity="few">Odstrániť %d udalosti?</item>
|
||||
<item quantity="many">Odstrániť %d udalostí?</item>
|
||||
<item quantity="other">Odstrániť %d udalostí?</item>
|
||||
</plurals>
|
||||
<plurals name="search_delete_done">
|
||||
<item quantity="one">%d udalosť odstránená</item>
|
||||
<item quantity="few">%d udalosti odstránené</item>
|
||||
<item quantity="many">%d odstránených udalostí</item>
|
||||
<item quantity="other">%d odstránených udalostí</item>
|
||||
</plurals>
|
||||
<string name="agenda_range_this_week">Tento týždeň</string>
|
||||
<string name="agenda_range_custom">Vlastné…</string>
|
||||
</resources>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user