Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eda616b42e | ||
|
|
7c17bf2ff4 | ||
|
|
9ca10fc6ee | ||
|
|
994fa010b8 | ||
|
|
5c20c5f6eb | ||
|
|
c4bb7ae0b2 | ||
|
|
0c985a280c | ||
|
|
17ebb9fd42 | ||
|
|
cadc2b14db | ||
|
|
1fdca903be | ||
|
|
5e7658aa80 | ||
|
|
bea31032fb | ||
|
|
f7c1dbdbc4 | ||
|
|
a1ed010963 | ||
|
|
7be6b7ac31 |
@@ -5,6 +5,83 @@ 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).
|
||||
|
||||
## [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
|
||||
@@ -1683,3 +1760,17 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#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
|
||||
|
||||
+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 = 22002
|
||||
versionName = "2.20.2"
|
||||
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")
|
||||
}
|
||||
|
||||
|
||||
@@ -113,11 +113,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
|
||||
@@ -242,6 +257,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 {
|
||||
|
||||
@@ -586,6 +586,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
|
||||
@@ -977,6 +995,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 =
|
||||
|
||||
@@ -253,6 +253,13 @@ 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) {
|
||||
|
||||
@@ -197,10 +197,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()
|
||||
}
|
||||
}
|
||||
@@ -510,11 +521,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
|
||||
},
|
||||
@@ -550,18 +578,33 @@ fun CalendarHost(
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ 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
|
||||
@@ -32,6 +34,8 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -106,6 +110,27 @@ fun AgendaScreen(
|
||||
}
|
||||
val successState = state as? AgendaUiState.Success
|
||||
|
||||
// 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 = {
|
||||
// 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()
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
@@ -139,14 +164,15 @@ fun AgendaScreen(
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = viewModel::goToToday,
|
||||
onToday = jumpToToday,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
CalendarFabColumn(
|
||||
todayVisible = !isOnToday && !todayInToolbar,
|
||||
todayVisible = (!isOnToday || (scrolledAway && successState != null)) &&
|
||||
!todayInToolbar,
|
||||
todayText = stringResource(R.string.agenda_today_action),
|
||||
onToday = viewModel::goToToday,
|
||||
onToday = jumpToToday,
|
||||
onCreate = { onCreateEvent(anchor, null) },
|
||||
)
|
||||
},
|
||||
@@ -195,6 +221,7 @@ fun AgendaScreen(
|
||||
state = state,
|
||||
pastDisplay = pastDisplay,
|
||||
showToday = showToday,
|
||||
listState = listState,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
@@ -298,6 +325,7 @@ internal fun AgendaContent(
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
) {
|
||||
when (state) {
|
||||
AgendaUiState.Loading -> Box(modifier)
|
||||
@@ -326,12 +354,16 @@ 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,
|
||||
@@ -349,6 +381,7 @@ private fun AgendaList(
|
||||
days: List<AgendaDay>,
|
||||
today: LocalDate,
|
||||
zone: TimeZone,
|
||||
listState: LazyListState,
|
||||
dimPast: Boolean,
|
||||
now: Instant,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
@@ -357,6 +390,7 @@ private fun AgendaList(
|
||||
) {
|
||||
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),
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
@@ -80,18 +84,13 @@ fun BackupScreen(
|
||||
onImport: (Uri) -> Unit,
|
||||
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 +131,42 @@ 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,
|
||||
) {
|
||||
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 +177,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 +312,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
|
||||
@@ -231,7 +246,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 +254,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 +331,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 +340,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 +358,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 +381,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 +435,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 +465,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 +706,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
|
||||
|
||||
@@ -32,6 +32,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 +132,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 +170,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 +205,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 +215,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,
|
||||
|
||||
@@ -4,19 +4,36 @@ 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
|
||||
@@ -26,52 +43,150 @@ 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) {
|
||||
fun CalendarFailure(
|
||||
reason: FailureReason,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val manageCalendars = LocalManageCalendars.current
|
||||
val titleRes = when (reason) {
|
||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
||||
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden
|
||||
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.AllCalendarsHidden -> R.string.state_failure_all_hidden_action
|
||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission_action
|
||||
else -> R.string.state_retry
|
||||
}
|
||||
val copy = failureCopy(reason)
|
||||
val onAction: () -> Unit = when (reason) {
|
||||
FailureReason.NoCalendarsConfigured -> {
|
||||
{ context.startCalendarSetup() }
|
||||
}
|
||||
FailureReason.AllCalendarsHidden -> manageCalendars ?: onRetry
|
||||
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))
|
||||
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(actionRes))
|
||||
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
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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 all-day strip height for every page, blended between the two pages a swipe
|
||||
* sits between so their timelines stay level mid-swipe. It follows the swipe
|
||||
* directly and only springs when a page's own strip 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 strip height, by page index.
|
||||
*/
|
||||
@Composable
|
||||
fun pagedStripHeight(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) },
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,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]
|
||||
@@ -83,8 +89,10 @@ fun calendarSlideTransition(
|
||||
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.
|
||||
// shears the pages against the viewport edge as they pass. Left off even
|
||||
// where the two pages differ in height — the split grid stands as many
|
||||
// rows as its month spans (#162) — since a page sliding out over the row
|
||||
// below it reads as travel, and the shear reads as a fault.
|
||||
sizeTransform = SizeTransform(clip = false),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -197,18 +196,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
|
||||
|
||||
@@ -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,7 +1,22 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerDefaults
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
|
||||
import de.jeanlucmakiola.calendula.ui.common.pagedStripHeight
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
@@ -43,12 +58,10 @@ 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
|
||||
@@ -83,9 +96,9 @@ 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.rememberBlockTextMetrics
|
||||
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
@@ -109,13 +122,11 @@ 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.calendula.ui.common.trimmedLines
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
||||
@@ -136,13 +147,16 @@ 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
|
||||
/** One lane of the all-day strip, sized to a trimmed bar line (#190). */
|
||||
private val ALL_DAY_ROW_HEIGHT = 20.dp
|
||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||
|
||||
/** Breathing room between the all-day strip and the scrolling timeline below. */
|
||||
private val TIMELINE_TOP_GAP = 8.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
|
||||
@@ -167,7 +181,7 @@ fun DayScreen(
|
||||
viewModel: DayViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val date by viewModel.date.collectAsStateWithLifecycle()
|
||||
val anchorPage by viewModel.anchorPage.collectAsStateWithLifecycle()
|
||||
|
||||
// When opened from the month grid, anchor to the tapped date.
|
||||
LaunchedEffect(initialDateIso) {
|
||||
@@ -177,36 +191,39 @@ fun DayScreen(
|
||||
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
|
||||
// Opens on the tapped date directly, rather than on today and then correcting.
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = initialDateIso?.let { dayPageFor(LocalDate.parse(it)) } ?: anchorPage,
|
||||
) { DAY_PAGE_COUNT }
|
||||
val pageSpec = rememberCalendarPageSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
// The pager leads and the anchor follows once it settles, so the anchor only
|
||||
// moves on its own to correct the pager — e.g. re-entry from the month grid
|
||||
// on another date. Snapped, since that is a correction rather than a move.
|
||||
LaunchedEffect(anchorPage) {
|
||||
if (pagerState.currentPage != anchorPage && !pagerState.isScrollInProgress) {
|
||||
pagerState.scrollToPage(anchorPage)
|
||||
}
|
||||
viewModel.goToToday()
|
||||
}
|
||||
// Drawer jump-to-date: slide from the side the target lies on.
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled)
|
||||
}
|
||||
// The day under the finger, so the title turns over mid-swipe, not after it.
|
||||
val date = dayForPage(pagerState.currentPage)
|
||||
|
||||
// Off the ticking clock rather than the loaded day, so it turns over at
|
||||
// midnight; derived, so the per-minute tick doesn't recompose the screen.
|
||||
val nowState = rememberCurrentMinute()
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } }
|
||||
val isOnToday = state !is DayUiState.Success || date == today
|
||||
|
||||
// Straight to the pager: a tap mid-fling must still land, and the anchor
|
||||
// hasn't caught up with the fling yet to tell the difference.
|
||||
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||
slideDir = if (target < date) -1 else 1
|
||||
viewModel.goToDate(target)
|
||||
scope.launch { pagerState.jumpToPage(dayPageFor(target), reduceMotion, pageSpec) }
|
||||
}
|
||||
val jumpToToday = { jumpToDate(today) }
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -238,7 +255,7 @@ fun DayScreen(
|
||||
topBar = {
|
||||
DayTopBar(
|
||||
date = date,
|
||||
currentYear = currentYear,
|
||||
currentYear = today.year,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
@@ -260,10 +277,9 @@ fun DayScreen(
|
||||
) { innerPadding ->
|
||||
DayContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
pagerState = pagerState,
|
||||
day = viewModel::day,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||
modifier = Modifier
|
||||
@@ -277,19 +293,16 @@ fun DayScreen(
|
||||
@Composable
|
||||
private fun DayContent(
|
||||
state: DayUiState,
|
||||
slideDir: Int,
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
pagerState: PagerState,
|
||||
day: (LocalDate) -> StateFlow<DayUiState>,
|
||||
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
|
||||
// Shared by every page and the gutter, 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()
|
||||
@@ -300,59 +313,45 @@ private fun DayContent(
|
||||
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.
|
||||
// Above the pager: 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() }
|
||||
val onDrop: (TimelineDrop) -> Unit = { drop ->
|
||||
move?.move(
|
||||
MoveRequest(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
// Loading and failure concern the provider as a whole, so they stand in
|
||||
// for the pager; a page still loading its own day handles that itself.
|
||||
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",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentKey = { it::class },
|
||||
transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) },
|
||||
label = "day-state",
|
||||
) { s ->
|
||||
when (s) {
|
||||
DayUiState.Loading -> DayLoading()
|
||||
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is DayUiState.Success -> DaySuccess(
|
||||
state = s,
|
||||
is DayUiState.Success -> DayPager(
|
||||
pagerState = pagerState,
|
||||
day = day,
|
||||
today = s.today,
|
||||
initialStripHeight = s.allDayStripHeight(),
|
||||
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)),
|
||||
),
|
||||
)
|
||||
},
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -360,6 +359,101 @@ private fun DayContent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The days as pages that follow the finger and snap once a swipe passes half a
|
||||
* page or is flung (#336), beside a gutter that stays put.
|
||||
*/
|
||||
@Composable
|
||||
private fun DayPager(
|
||||
pagerState: PagerState,
|
||||
day: (LocalDate) -> StateFlow<DayUiState>,
|
||||
today: LocalDate,
|
||||
initialStripHeight: Dp,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
|
||||
val allDayHeight = pagedStripHeight(pagerState, stripHeights, initialStripHeight)
|
||||
DayFrame(allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
// The days either side are composed ahead, so a swipe lands on a
|
||||
// loaded day rather than watching it fill in.
|
||||
beyondViewportPageCount = 1,
|
||||
// A held block moves within its day; the page stays put under it.
|
||||
userScrollEnabled = !dragController.isDragging,
|
||||
flingBehavior = PagerDefaults.flingBehavior(
|
||||
state = pagerState,
|
||||
snapAnimationSpec = rememberCalendarPageSpec(),
|
||||
),
|
||||
) { page ->
|
||||
val date = dayForPage(page)
|
||||
val pageState by remember(date) { day(date) }.collectAsStateWithLifecycle()
|
||||
val loaded = pageState as? DayUiState.Success
|
||||
// Until its day arrives a page shows the empty column.
|
||||
val empty = remember(date, today) {
|
||||
DayUiState.Success(date = date, today = today, allDay = emptyList(), timed = emptyList())
|
||||
}
|
||||
val stripHeight = loaded?.allDayStripHeight()
|
||||
DisposableEffect(page, stripHeight) {
|
||||
if (stripHeight != null) stripHeights[page] = stripHeight
|
||||
onDispose { stripHeights.remove(page) }
|
||||
}
|
||||
DayPage(
|
||||
state = loaded ?: empty,
|
||||
allDayHeight = allDayHeight,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
active = page == pagerState.currentPage,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What stays put while the days swipe: the hour gutter, with [pages] filling the rest. */
|
||||
@Composable
|
||||
private fun DayFrame(
|
||||
allDayHeight: Dp,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
pages: @Composable () -> Unit,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.width(GUTTER_WIDTH).fillMaxHeight()) {
|
||||
Spacer(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(allDayHeight)
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
)
|
||||
Spacer(Modifier.height(TIMELINE_TOP_GAP))
|
||||
// Resolves the hour height off the same viewport height the pages'
|
||||
// timelines have, so labels and column agree.
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val zoom = LocalTimelineZoom.current
|
||||
val hourHeight = zoom.scale.hourHeight(maxHeight)
|
||||
// Start inset so the labels centre on the top bar hamburger,
|
||||
// matching the week view.
|
||||
HourGutter(
|
||||
scrollState = scrollState,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
modifier = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom),
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f).fillMaxHeight()) { pages() }
|
||||
}
|
||||
}
|
||||
|
||||
/** A single day in its frame, without the pager — for the Settings preview. */
|
||||
@Composable
|
||||
internal fun DaySuccess(
|
||||
state: DayUiState.Success,
|
||||
@@ -369,6 +463,37 @@ internal fun DaySuccess(
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
DayFrame(allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController) {
|
||||
DayPage(
|
||||
state = state,
|
||||
allDayHeight = allDayHeight,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
active = true,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One day's page: all-day strip and column.
|
||||
*
|
||||
* @param active whether this is the page on screen, the only one that may
|
||||
* publish the drag geometry.
|
||||
*/
|
||||
@Composable
|
||||
private fun DayPage(
|
||||
state: DayUiState.Success,
|
||||
allDayHeight: Dp,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
active: Boolean,
|
||||
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,
|
||||
@@ -387,13 +512,12 @@ internal fun DaySuccess(
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
)
|
||||
}
|
||||
// Breathing room between the top section and the scrolling timeline
|
||||
// below.
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Spacer(Modifier.height(TIMELINE_TOP_GAP))
|
||||
Timeline(
|
||||
state = state,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
active = active,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
@@ -481,8 +605,6 @@ private fun AllDayStrip(
|
||||
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.
|
||||
@@ -529,7 +651,7 @@ private fun AllDayBar(
|
||||
val titleOverflow = eventTitleOverflow()
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
style = MaterialTheme.typography.labelSmall.trimmedLines(),
|
||||
maxLines = 1,
|
||||
overflow = titleOverflow.overflow,
|
||||
softWrap = titleOverflow.softWrap,
|
||||
@@ -545,6 +667,7 @@ private fun Timeline(
|
||||
state: DayUiState.Success,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
active: Boolean,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
@@ -566,61 +689,53 @@ private fun Timeline(
|
||||
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.
|
||||
// The pinch sits above the scroll viewport: it has to outrank the
|
||||
// vertical scroll, and it does that by watching the initial pass, which
|
||||
// only reaches it if it is the viewport's 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,
|
||||
// Scrolls on the same state as the gutter and the other pages, so they
|
||||
// all stay aligned. A static, rounded-clipped window — the content
|
||||
// scrolls inside it, so the soft corners are permanent at any scroll
|
||||
// position.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(pinch)
|
||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.verticalScroll(scrollState)
|
||||
.onGloballyPositioned { if (active) dragController.geometry.viewport = it },
|
||||
) {
|
||||
DayColumnCard(
|
||||
blocks = state.timed,
|
||||
dark = dark,
|
||||
date = state.date,
|
||||
today = state.today,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
)
|
||||
// Day column: rounded, clipped scroll viewport (permanent corners).
|
||||
Box(
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
.fillMaxWidth()
|
||||
.height(totalHeight)
|
||||
// The scrolling content itself, so its root position
|
||||
// already folds in the scroll offset. Only the page on
|
||||
// screen publishes it: the ones either side are laid out too.
|
||||
.onGloballyPositioned { coords ->
|
||||
if (!active) return@onGloballyPositioned
|
||||
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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -763,30 +878,24 @@ private fun EventBlock(
|
||||
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()
|
||||
}
|
||||
val metrics = rememberBlockTextMetrics(height)
|
||||
// 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 showTime = metrics.available >= metrics.titleLine + metrics.timeLine
|
||||
val showTitle = metrics.fitsTitle
|
||||
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 titleBudget = metrics.titleBudget(metrics.available).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 spare = metrics.available - metrics.titleHeight(titleMaxLines) -
|
||||
if (showTime) metrics.timeLine else 0.dp
|
||||
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
|
||||
val paint = eventPaint(block.event, dark)
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
@@ -827,7 +936,7 @@ private fun EventBlock(
|
||||
// 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)
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
|
||||
.semantics {
|
||||
contentDescription = "$title, $timeLabel"
|
||||
if (moveAction != null) customActions = listOf(moveAction)
|
||||
|
||||
@@ -9,6 +9,7 @@ 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.ui.week.layoutAllDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -18,21 +19,25 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNot
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.abs
|
||||
|
||||
/** How far from the requested day [DayViewModel.day] keeps other days cached. */
|
||||
private const val DAY_CACHE_DAYS = 7
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
@@ -47,32 +52,53 @@ class DayViewModel @Inject constructor(
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
private val _date = MutableStateFlow(todayDate)
|
||||
val date: StateFlow<LocalDate> = _date
|
||||
|
||||
/** The pager page the anchor day sits on. */
|
||||
val anchorPage: StateFlow<Int> = _date
|
||||
.map { dayPageFor(it) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = dayPageFor(todayDate),
|
||||
)
|
||||
|
||||
/**
|
||||
* The anchor day's state. Once loaded it never falls back to [DayUiState.Loading]:
|
||||
* moving the anchor keeps the last result until the new day arrives, since
|
||||
* the pages draw their own days and this only gates the failure screen.
|
||||
*/
|
||||
val state: StateFlow<DayUiState> = _date
|
||||
.flatMapLatest { day ->
|
||||
val range = dayRange(day, zone)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
) { calendars, instances ->
|
||||
buildState(day, calendars, instances)
|
||||
}
|
||||
}
|
||||
.flatMapLatest { date -> day(date).filterNot { it is DayUiState.Loading } }
|
||||
.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)
|
||||
private val days = PageStateCache<LocalDate, DayUiState>(
|
||||
scope = viewModelScope,
|
||||
initial = DayUiState.Loading,
|
||||
// Far enough to cover the pages either side and a swipe back, so paging
|
||||
// through a month doesn't keep a month of queries around.
|
||||
keep = { cached, requested -> abs(cached.daysUntil(requested)) <= DAY_CACHE_DAYS },
|
||||
) { date ->
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(dayRange(date, zone)),
|
||||
) { calendars, instances ->
|
||||
buildState(date, calendars, instances)
|
||||
}
|
||||
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
}
|
||||
|
||||
fun goToNext() {
|
||||
_date.value = _date.value.plus(1, DateTimeUnit.DAY)
|
||||
/** The day [date], shared between its pager page and [state]. Main thread only. */
|
||||
fun day(date: LocalDate): StateFlow<DayUiState> = days.get(date)
|
||||
|
||||
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
|
||||
fun onPageSettled(page: Int) {
|
||||
if (dayPageFor(_date.value) != page) _date.value = dayForPage(page)
|
||||
}
|
||||
|
||||
fun goToToday() {
|
||||
|
||||
@@ -302,6 +302,7 @@ fun EventDetailScreen(
|
||||
is EventDetailUiState.Failure -> CalendarFailure(
|
||||
reason = s.reason,
|
||||
onRetry = viewModel::retry,
|
||||
modifier = contentModifier,
|
||||
)
|
||||
is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier)
|
||||
}
|
||||
@@ -526,7 +527,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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -620,7 +620,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 +721,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) },
|
||||
|
||||
@@ -73,19 +73,19 @@ 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()),
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -123,6 +123,7 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -153,6 +154,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
||||
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.monthBarShape
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
@@ -744,7 +746,7 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
|
||||
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
|
||||
) {
|
||||
// Reserve the gutter so the weekday labels stay over their day columns.
|
||||
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER))
|
||||
if (showWeekNumbers) Spacer(Modifier.width(rememberWeekNumberGutter()))
|
||||
days.forEach { dow ->
|
||||
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
|
||||
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
|
||||
@@ -762,9 +764,34 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
|
||||
|
||||
private val EVENT_ROW_HEIGHT = 20.dp
|
||||
private val DAY_NUMBER_HEIGHT = 22.dp
|
||||
/** Width of the optional left calendar-week gutter (#25); narrow, since it only
|
||||
* seats a one- or two-digit week number in a full-height tonal pill. */
|
||||
private val WEEK_NUMBER_GUTTER = 40.dp
|
||||
/** Padding between the week-number pill's edge and the number inside it. */
|
||||
private val WEEK_NUMBER_PADDING = 6.dp
|
||||
|
||||
/** The widest week number an ISO year reaches; digits are tabular, so one
|
||||
* measurement of it prices every week in the grid. */
|
||||
private const val WEEK_NUMBER_SAMPLE = "53"
|
||||
|
||||
/**
|
||||
* Width of the optional left calendar-week gutter (#25), measured rather than
|
||||
* fixed: it is sized to the number it seats at the style the pill draws it in,
|
||||
* so it follows the font scale instead of reserving slack for it, and spends
|
||||
* nothing more on a column the grid would rather hand to the seven days (#213).
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberWeekNumberGutter(): Dp {
|
||||
val measurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
val style = weekNumberStyle()
|
||||
return remember(style, density, measurer) {
|
||||
val text = with(density) { measurer.measure(WEEK_NUMBER_SAMPLE, style).size.width.toDp() }
|
||||
text + (WEEK_NUMBER_PADDING + CELL_GAP) * 2
|
||||
}
|
||||
}
|
||||
|
||||
/** The week number's own style — a step down from the day numbers beside it. */
|
||||
@Composable
|
||||
private fun weekNumberStyle() =
|
||||
MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold)
|
||||
private val DAY_NUMBER_GAP = 4.dp
|
||||
private val CELL_TOP_PADDING = 6.dp
|
||||
/** Named separately because the split style's selection outline draws its own
|
||||
@@ -774,14 +801,79 @@ private val CELL_SHAPE = RoundedCornerShape(CELL_CORNER)
|
||||
|
||||
/** Width of the split style's selected-day outline. */
|
||||
private val SPLIT_SELECTION_STROKE = 1.5.dp
|
||||
/** Lanes of bars/pills a day cell draws before the rest become overflow dots. */
|
||||
internal const val MAX_EVENT_ROWS = 3
|
||||
/**
|
||||
* Dots a split-style cell draws. Fixed, unlike the paged grid's measured cap:
|
||||
* the split rows are a fixed height whatever the screen is.
|
||||
*
|
||||
* Dot *i* is lane *i*, so a dot morphs into the bar the expanded grid draws
|
||||
* there (#53) — [MonthWeek.laneEvents] seats the same events in the same order
|
||||
* at any cap, so a larger one only appends. Where the expanded grid seats fewer
|
||||
* lanes than this — a six-row month in a short landscape viewport — the dots
|
||||
* past its cap have no bar to become and simply fade instead of travelling.
|
||||
*/
|
||||
internal const val SPLIT_DOT_LANES = 3
|
||||
|
||||
/** Diameter of a single overflow dot. */
|
||||
private val OVERFLOW_DOT_SIZE = 6.dp
|
||||
|
||||
/**
|
||||
* Height of the overflow row: the "+N" beside the dots, measured at the style it
|
||||
* is drawn in.
|
||||
*
|
||||
* It was taking a whole event lane, which is more than a dot and a label line
|
||||
* need and one lane fewer for the chips. Measured rather than picked, so it
|
||||
* still holds the label at a large font scale — a fixed height clipped the "+N"
|
||||
* at anything above the default.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberOverflowRowHeight(): Dp {
|
||||
val measurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
val style = MaterialTheme.typography.labelSmall
|
||||
return remember(style, density, measurer) {
|
||||
with(density) { measurer.measure(OVERFLOW_SAMPLE, style).size.height.toDp() }
|
||||
.coerceAtLeast(OVERFLOW_DOT_SIZE)
|
||||
}
|
||||
}
|
||||
|
||||
/** The tallest the counter gets; digits are tabular, so one is as wide as any. */
|
||||
private const val OVERFLOW_SAMPLE = "+9"
|
||||
|
||||
/**
|
||||
* Lanes of chips this week's cells draw in a row [rowHeight] tall, before the
|
||||
* rest become dots.
|
||||
*
|
||||
* The cap used to be a flat three at every size, so a tall five-row month threw
|
||||
* away lanes it had the room for while a cramped six-row one drew a third chip
|
||||
* its band could not hold and put the dots below the clip, where nothing showed
|
||||
* that the day held more at all.
|
||||
*
|
||||
* Measured instead, with no ceiling: a cell spends whatever height it was given.
|
||||
* The overflow row is only charged for when some day in the week actually
|
||||
* overflows — a week that fits gets that space as another lane rather than
|
||||
* reserving room for a marker it will not draw.
|
||||
*/
|
||||
internal fun MonthWeek.laneCapFor(rowHeight: Dp, overflowRow: Dp): Int {
|
||||
val band = monthBandHeight(rowHeight)
|
||||
val full = (band / EVENT_ROW_HEIGHT).toInt().coerceAtLeast(1)
|
||||
if (!overflowsAt(full)) return full
|
||||
return ((band - overflowRow) / EVENT_ROW_HEIGHT).toInt().coerceAtLeast(1)
|
||||
}
|
||||
|
||||
/** What a row [rowHeight] tall leaves for chips once the day number is drawn. */
|
||||
internal fun monthBandHeight(rowHeight: Dp): Dp =
|
||||
rowHeight - CELL_TOP_PADDING - DAY_NUMBER_HEIGHT - DAY_NUMBER_GAP
|
||||
|
||||
/** Whether any day in this week holds more than [lanes] lanes can seat. */
|
||||
private fun MonthWeek.overflowsAt(lanes: Int): Boolean =
|
||||
days.withIndex().any { (col, day) -> overflowEvents(col, day, lanes).isNotEmpty() }
|
||||
|
||||
/**
|
||||
* Row height in the continuous grid. The paged grid divides the viewport between
|
||||
* however many rows the month has; a scrolling stream has no such bound, so it
|
||||
* fixes a height that seats the day number plus [MAX_EVENT_ROWS] event rows —
|
||||
* close to what a five-row month gets on a typical phone.
|
||||
* fixes one — close to what a five-row month gets on a typical phone. How many
|
||||
* chips that seats is [MonthWeek.laneCapFor]'s answer like anywhere else, so a
|
||||
* week that does not overflow fills the band rather than holding a lane back.
|
||||
*/
|
||||
private val CONTINUOUS_ROW_HEIGHT = 112.dp
|
||||
|
||||
@@ -793,6 +885,11 @@ private val CONTINUOUS_ROW_HEIGHT = 112.dp
|
||||
*/
|
||||
private val CONTINUOUS_MONTH_GAP = 20.dp
|
||||
|
||||
/** The paged grid's own vertical padding, and the gap between its week rows —
|
||||
* named because [monthLaneCap] has to take them off the viewport first. */
|
||||
private val GRID_VERTICAL_PADDING = 4.dp
|
||||
private val GRID_ROW_GAP = 2.dp
|
||||
|
||||
/** Gap between the weekday header and the seamless stream's first week row. */
|
||||
private val DENSE_HEADER_GAP = 4.dp
|
||||
|
||||
@@ -805,34 +902,43 @@ internal fun MonthGrid(
|
||||
/** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */
|
||||
selected: LocalDate? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// Match the weekday header's inset so day cells sit under their
|
||||
// labels, and so the week-number gutter's centre lines up with the
|
||||
// top bar's hamburger (4dp bar inset + 24dp half icon button).
|
||||
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
val month = state.month
|
||||
// Once per grid: the value depends on the typography, the density, the
|
||||
// locale and the 24-hour setting, none of which vary by row (#219).
|
||||
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||
state.weeks.forEach { week ->
|
||||
MonthWeekRow(
|
||||
week = week,
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
timeChipWidth = timeChipWidth,
|
||||
inMonth = { it.month == month.month && it.year == month.year },
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
selected = selected,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
)
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
// The rows divide whatever the viewport leaves once the Column's own
|
||||
// padding and the gaps between them are paid, so how many chips a cell
|
||||
// can seat is only knowable here.
|
||||
val rows = state.weeks.size.coerceAtLeast(1)
|
||||
val rowHeight =
|
||||
(maxHeight - GRID_VERTICAL_PADDING * 2 - GRID_ROW_GAP * (rows - 1)) / rows
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// Match the weekday header's inset so day cells sit under their
|
||||
// labels, and so the week-number gutter's centre lines up with the
|
||||
// top bar's hamburger (4dp bar inset + 24dp half icon button).
|
||||
.padding(horizontal = AppBarSpacing.Inset, vertical = GRID_VERTICAL_PADDING),
|
||||
verticalArrangement = Arrangement.spacedBy(GRID_ROW_GAP),
|
||||
) {
|
||||
val month = state.month
|
||||
// Once per grid: the value depends on the typography, the density, the
|
||||
// locale and the 24-hour setting, none of which vary by row (#219).
|
||||
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||
state.weeks.forEach { week ->
|
||||
MonthWeekRow(
|
||||
week = week,
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
timeChipWidth = timeChipWidth,
|
||||
rowHeight = rowHeight,
|
||||
inMonth = { it.month == month.month && it.year == month.year },
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
selected = selected,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -930,6 +1036,7 @@ private fun ContinuousMonthBlock(
|
||||
today = today,
|
||||
zone = zone,
|
||||
timeChipWidth = timeChipWidth,
|
||||
rowHeight = CONTINUOUS_ROW_HEIGHT,
|
||||
inMonth = { it.month == month.month && it.year == month.year },
|
||||
// The block owns its month alone: a day from either
|
||||
// neighbour is left out entirely rather than dimmed.
|
||||
@@ -1024,6 +1131,7 @@ internal fun DenseMonthGrid(
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
timeChipWidth = timeChipWidth,
|
||||
rowHeight = CONTINUOUS_ROW_HEIGHT,
|
||||
// Every day in the stream belongs to a month equally — there
|
||||
// is no "other month" to recede here.
|
||||
inMonth = { true },
|
||||
@@ -1141,10 +1249,10 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp
|
||||
* lists whatever day is selected — and a downward drag trades the pane away for
|
||||
* the full paged grid, an upward one brings it back (#53).
|
||||
*
|
||||
* The grid slides between months like the paged style, which it can only do
|
||||
* because it always reserves [SPLIT_GRID_ROWS] rows. Sized to its own month it
|
||||
* stood 4–6 rows tall, so every swipe shunted the pane up or down by a row on
|
||||
* top of swapping the grid — the pane now holds still and only the grid moves.
|
||||
* The grid slides between months like the paged style, and stands only as many
|
||||
* rows tall as its own month spans (#162). A swipe between a five-row month and
|
||||
* a six-row one therefore moves the pane by a row as well as swapping the grid;
|
||||
* the row it hands back is worth more to the pane than a still edge is.
|
||||
*
|
||||
* Expansion is deliberately **not** a stored preference. It is a way to look at
|
||||
* the month you are on, not a fourth style; persisted, someone would expand it
|
||||
@@ -1437,17 +1545,10 @@ private fun SplitExpandHandle(
|
||||
*/
|
||||
private val SPLIT_ROW_HEIGHT = 46.dp
|
||||
private val SPLIT_DOT_SIZE = 5.dp
|
||||
// Dots are capped by MAX_EVENT_ROWS, not a constant of their own: they stand for
|
||||
// Dots are capped by SPLIT_DOT_LANES, not a constant of their own: they stand for
|
||||
// the paged grid's lanes, so the two caps have to be the same number or a dot
|
||||
// would have no bar to become (#53).
|
||||
|
||||
/**
|
||||
* Rows the split grid always reserves — the most any month needs. A month that
|
||||
* fits in fewer pads the remainder with blank rows rather than shrinking, which
|
||||
* is what lets the pane below hold still from month to month.
|
||||
*/
|
||||
private const val SPLIT_GRID_ROWS = 6
|
||||
|
||||
/**
|
||||
* The expand handle: M3's drag-handle pill (32×4dp), in a row tall enough to be a
|
||||
* comfortable tap target on its own.
|
||||
@@ -1460,6 +1561,11 @@ private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp
|
||||
* The split style's grid (#53): the month compressed to day numbers and event
|
||||
* dots, with the selected day listed underneath by [SplitDayPane].
|
||||
*
|
||||
* Only the rows the month actually spans. It used to pad every month out to six
|
||||
* so the pane below held still from page to page, but a row is a sixth of the
|
||||
* grid and a third of what the pane gets to show — too much to leave blank on
|
||||
* the months that don't need it (#162).
|
||||
*
|
||||
* Tapping selects rather than drilling into the Day view — the pane is the
|
||||
* answer to "what's on this day", so opening a whole screen for it would defeat
|
||||
* the layout. The full Day view stays one tap away on the pane's date header.
|
||||
@@ -1486,7 +1592,7 @@ internal fun SplitMonthGrid(
|
||||
WeekNumberGutter(
|
||||
weekStart = week.days.first(),
|
||||
modifier = Modifier
|
||||
.width(WEEK_NUMBER_GUTTER)
|
||||
.width(rememberWeekNumberGutter())
|
||||
.fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
@@ -1494,11 +1600,11 @@ internal fun SplitMonthGrid(
|
||||
val inMonth = day.month == month.month && day.year == month.year
|
||||
// Seated by lane rather than gathered by colour, so each dot
|
||||
// is the event the expanded grid draws in that same lane.
|
||||
val seated = week.laneEvents(col, day, MAX_EVENT_ROWS)
|
||||
val seated = week.laneEvents(col, day, SPLIT_DOT_LANES)
|
||||
SplitDayCell(
|
||||
date = day,
|
||||
events = seated,
|
||||
hidden = week.overflowEvents(col, day, MAX_EVENT_ROWS),
|
||||
hidden = week.overflowEvents(col, day, SPLIT_DOT_LANES),
|
||||
isToday = day == state.today,
|
||||
// A page marks only the days its own month owns. Paging
|
||||
// moves the selection before this month's replacement
|
||||
@@ -1518,17 +1624,11 @@ internal fun SplitMonthGrid(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hold the grid at a constant height whatever shape the month is, so the
|
||||
// pane beneath it doesn't move as you page and one month can slide over
|
||||
// another without a height change under it.
|
||||
repeat(SPLIT_GRID_ROWS - state.weeks.size) {
|
||||
Spacer(Modifier.fillMaxWidth().height(SPLIT_ROW_HEIGHT))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One compact day: its number over up to [MAX_EVENT_ROWS] lane-seated event dots.
|
||||
* One compact day: its number over up to [SPLIT_DOT_LANES] lane-seated event dots.
|
||||
*
|
||||
* Selection and today are deliberately different signals — a tinted, outlined
|
||||
* cell versus the filled circle the other views already use for today — so the
|
||||
@@ -1902,7 +2002,7 @@ private fun rememberSkeletonPulse(): Float {
|
||||
* One week of the grid. Bars (all-day / multi-day) are positioned absolutely so
|
||||
* a multi-day event is one connected bar across the columns; single-day timed
|
||||
* events sit beneath them as filled pills in their own cell. The cap is
|
||||
* [MAX_EVENT_ROWS] rows of bars+pills, then a "+N" dot indicator per day.
|
||||
* [SPLIT_DOT_LANES] rows of bars+pills, then a "+N" dot indicator per day.
|
||||
* A transparent per-day layer on top turns a tap into "open that day".
|
||||
*/
|
||||
@Composable
|
||||
@@ -1913,6 +2013,8 @@ private fun MonthWeekRow(
|
||||
zone: TimeZone,
|
||||
/** The narrowest chip that may carry a start time, measured once per grid (#219). */
|
||||
timeChipWidth: Dp,
|
||||
/** The height this row was given, which decides its lanes — see [laneCapFor]. */
|
||||
rowHeight: Dp,
|
||||
inMonth: (LocalDate) -> Boolean,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
@@ -1929,8 +2031,10 @@ private fun MonthWeekRow(
|
||||
selected: LocalDate? = null,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val overflowRow = rememberOverflowRowHeight()
|
||||
val laneCap = week.laneCapFor(rowHeight, overflowRow)
|
||||
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
||||
val shownLanes = laneCount.coerceAtMost(laneCap)
|
||||
val morphing = morphInFlight()
|
||||
// Every chip's start time for this row at once, and only when the row's own
|
||||
// inputs change: formatting is a parsed pattern per call, and the dim cutoff
|
||||
@@ -1983,9 +2087,9 @@ private fun MonthWeekRow(
|
||||
band = bandCoordinates[0],
|
||||
columnWidthPx = cell.size.width / 7f,
|
||||
laneHeightPx = rowHeightPx,
|
||||
laneCount = MAX_EVENT_ROWS,
|
||||
laneCount = laneCap,
|
||||
isRtl = isRtl,
|
||||
chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) },
|
||||
chipAt = { col, lane -> week.chipAt(col, lane, laneCap) },
|
||||
chipStart = { col, lane -> week.chipStartCol(col, lane) },
|
||||
),
|
||||
)
|
||||
@@ -2007,7 +2111,7 @@ private fun MonthWeekRow(
|
||||
WeekNumberGutter(
|
||||
weekStart = week.days.first(),
|
||||
modifier = Modifier
|
||||
.width(WEEK_NUMBER_GUTTER)
|
||||
.width(rememberWeekNumberGutter())
|
||||
.fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
@@ -2026,6 +2130,7 @@ private fun MonthWeekRow(
|
||||
controller = dragController,
|
||||
band = bandCoordinates,
|
||||
rowHeightPx = rowHeightPx,
|
||||
laneCap = laneCap,
|
||||
isRtl = isRtl,
|
||||
chipTimes = chipTimes,
|
||||
),
|
||||
@@ -2034,6 +2139,11 @@ private fun MonthWeekRow(
|
||||
// What a chip has to spend, against the [timeChipWidth] a start time
|
||||
// costs it (#219).
|
||||
val colW = maxWidth / 7
|
||||
// Held here rather than read at the offset: the dots are placed
|
||||
// inside a plain lambda, which is no longer in this scope. The box
|
||||
// is the whole row, so the day number's share comes off it — the
|
||||
// dots are positioned inside the band, not inside this.
|
||||
val bandHeight = monthBandHeight(maxHeight)
|
||||
|
||||
// Per-day background pills — same surfaceContainer rounded surface the
|
||||
// week/day views use, so the three views share one visual language.
|
||||
@@ -2176,7 +2286,7 @@ private fun MonthWeekRow(
|
||||
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
|
||||
.map { it.lane }
|
||||
.toSet()
|
||||
val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied }
|
||||
val freeSlots = (0 until laneCap).filter { it !in occupied }
|
||||
val pillsShown = timed.take(freeSlots.size)
|
||||
pillsShown.forEachIndexed { i, ev ->
|
||||
MonthBar(
|
||||
@@ -2210,8 +2320,20 @@ private fun MonthWeekRow(
|
||||
events = hiddenEvents,
|
||||
total = hidden,
|
||||
dark = dark,
|
||||
rowHeight = overflowRow,
|
||||
modifier = Modifier
|
||||
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
|
||||
// After the chip lanes, but never past the
|
||||
// band: on a row too short for the lanes it
|
||||
// is holding, dots placed below it are
|
||||
// clipped away entirely and the day looks
|
||||
// like it has nothing more to show.
|
||||
.offset(
|
||||
x = colW * col,
|
||||
y = minOf(
|
||||
EVENT_ROW_HEIGHT * laneCap,
|
||||
bandHeight - overflowRow,
|
||||
),
|
||||
)
|
||||
.morphBounds(MonthMorphKey.Overflow(d))
|
||||
.width(colW)
|
||||
.padding(horizontal = 3.dp),
|
||||
@@ -2286,6 +2408,7 @@ private fun MonthWeekRow(
|
||||
bandCoordinates,
|
||||
),
|
||||
rowHeightPx = rowHeightPx,
|
||||
laneCap = laneCap,
|
||||
)
|
||||
if (chip != null) onEventClick(chip) else onOpenDay(d)
|
||||
},
|
||||
@@ -2327,11 +2450,12 @@ internal fun MonthWeek.chipAtCellY(
|
||||
cellY: Float,
|
||||
bandTopInCell: Float?,
|
||||
rowHeightPx: Float,
|
||||
laneCap: Int,
|
||||
): EventInstance? {
|
||||
if (bandTopInCell == null || rowHeightPx <= 0f) return null
|
||||
val bandY = cellY - bandTopInCell
|
||||
if (bandY < 0f) return null
|
||||
return chipAt(col, (bandY / rowHeightPx).toInt(), MAX_EVENT_ROWS)
|
||||
return chipAt(col, (bandY / rowHeightPx).toInt(), laneCap)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2346,6 +2470,7 @@ private fun monthChipDragModifier(
|
||||
controller: MonthDragController?,
|
||||
band: Array<LayoutCoordinates?>,
|
||||
rowHeightPx: Float,
|
||||
laneCap: Int,
|
||||
isRtl: Boolean,
|
||||
/** The row's formatted chip times by instance id, so the copy carries the
|
||||
* one its source chip had rather than deriving another (#219). */
|
||||
@@ -2364,7 +2489,7 @@ private fun monthChipDragModifier(
|
||||
val event = if (bandY < 0f || columnPx <= 0f) {
|
||||
null
|
||||
} else {
|
||||
week.chipAt(dayIndex, lane, MAX_EVENT_ROWS)
|
||||
week.chipAt(dayIndex, lane, laneCap)
|
||||
}
|
||||
if (event == null || moveScope?.allows(event) != true) {
|
||||
false
|
||||
@@ -2422,8 +2547,7 @@ private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier
|
||||
) {
|
||||
Text(
|
||||
text = weekNumber.toString(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = weekNumberStyle(),
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
@@ -2544,7 +2668,7 @@ private fun MonthBar(
|
||||
val titleOverflow = eventTitleOverflow()
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
style = MaterialTheme.typography.labelSmall.trimmedLines(),
|
||||
maxLines = 1,
|
||||
overflow = titleOverflow.overflow,
|
||||
softWrap = titleOverflow.softWrap,
|
||||
@@ -2565,6 +2689,7 @@ private fun OverflowDots(
|
||||
events: List<EventInstance>,
|
||||
total: Int,
|
||||
dark: Boolean,
|
||||
rowHeight: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val soften = LocalSoftenColors.current
|
||||
@@ -2572,14 +2697,14 @@ private fun OverflowDots(
|
||||
val byColor = events.groupBy { it.color }
|
||||
val dots = byColor.keys.take(3)
|
||||
Row(
|
||||
modifier = modifier.height(EVENT_ROW_HEIGHT),
|
||||
modifier = modifier.height(rowHeight),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
dots.forEach { argb ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(6.dp)
|
||||
.size(OVERFLOW_DOT_SIZE)
|
||||
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
|
||||
.background(eventAccent(argb, dark, soften), CircleShape),
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -509,6 +509,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 +524,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,
|
||||
|
||||
@@ -143,7 +143,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,
|
||||
|
||||
@@ -181,7 +181,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 +198,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 = {
|
||||
|
||||
@@ -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,25 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,7 +1,21 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerDefaults
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
|
||||
import de.jeanlucmakiola.calendula.ui.common.pagedStripHeight
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
@@ -48,12 +62,10 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
@@ -69,6 +81,7 @@ import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -91,11 +104,11 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
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.blockTextLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberBlockTextMetrics
|
||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
@@ -118,12 +131,11 @@ import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
||||
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
||||
import de.jeanlucmakiola.calendula.ui.common.withTitleWeight
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
@@ -141,7 +153,6 @@ 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.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -151,15 +162,23 @@ import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||
/** One lane of the all-day strip, sized to a trimmed bar line (#190). */
|
||||
private val ALL_DAY_ROW_HEIGHT = 20.dp
|
||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||
/** Gap between day columns; part of the column pitch a drag maps positions through. */
|
||||
private val COLUMN_GAP = 2.dp
|
||||
|
||||
/** Breathing room between the header block and the scrolling timeline below. */
|
||||
private val TIMELINE_TOP_GAP = 8.dp
|
||||
private val HEADER_TOP_PADDING = 4.dp
|
||||
private val HEADER_BOTTOM_PADDING = 8.dp
|
||||
|
||||
/** The header's date slot, reserved whether or not it holds today's circle. */
|
||||
private val DATE_SLOT_SIZE = 28.dp
|
||||
|
||||
/** Total all-day strip height for a week (0 when there are no all-day events). */
|
||||
internal fun WeekUiState.Success.allDayStripHeight(): Dp {
|
||||
if (allDaySpans.isEmpty()) return 0.dp
|
||||
@@ -184,7 +203,8 @@ fun WeekScreen(
|
||||
viewModel: WeekViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle()
|
||||
val firstDay by viewModel.firstDayOfWeek.collectAsStateWithLifecycle()
|
||||
val anchorPage by viewModel.anchorPage.collectAsStateWithLifecycle()
|
||||
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||
// The instant before which an event counts as completed, or null when dimming
|
||||
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||
@@ -197,39 +217,41 @@ fun WeekScreen(
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val isOnCurrentWeek = when (val s = state) {
|
||||
// True when today falls inside the displayed week — independent of which
|
||||
// weekday the user picked as the first day.
|
||||
is WeekUiState.Success ->
|
||||
s.today >= s.weekStart && s.today <= s.weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
||||
else -> true
|
||||
}
|
||||
|
||||
// Drives whether the title carries the year. Falls back to the clock only
|
||||
// while the first load is in flight, when there is no state to read today from.
|
||||
val currentYear = when (val s = state) {
|
||||
is WeekUiState.Success -> s.today.year
|
||||
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
||||
}
|
||||
|
||||
// Slide direction for the week transition: +1 = next, -1 = prev, 0 = jump.
|
||||
var slideDir by remember { mutableIntStateOf(0) }
|
||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||
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 WeekUiState.Success -> if (s.today < s.weekStart) -1 else 1
|
||||
else -> 0
|
||||
val pagerState = rememberPagerState(initialPage = anchorPage) { WEEK_PAGE_COUNT }
|
||||
val pageSpec = rememberCalendarPageSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
// The pager leads and the anchor follows once it settles, so the only thing
|
||||
// left moving the anchor on its own is the first day re-framing the week —
|
||||
// including the stored preference arriving after the pager opened on the
|
||||
// Monday default. Snapped, since that is a correction rather than a move.
|
||||
LaunchedEffect(anchorPage) {
|
||||
if (pagerState.currentPage != anchorPage && !pagerState.isScrollInProgress) {
|
||||
pagerState.scrollToPage(anchorPage)
|
||||
}
|
||||
viewModel.goToToday()
|
||||
}
|
||||
// Drawer jump-to-date: slide from the side the target week lies on.
|
||||
// The page it opened on is skipped: a first-day change landing in between
|
||||
// would read it as a different week.
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled)
|
||||
}
|
||||
// The week under the finger, so the title turns over mid-swipe, not after it.
|
||||
val weekStart = weekStartForPage(pagerState.currentPage, firstDay)
|
||||
|
||||
// Off the ticking clock rather than the loaded week, so it turns over at
|
||||
// midnight; derived, so the per-minute tick doesn't recompose the screen.
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } }
|
||||
// Independent of which weekday the user picked as the first day.
|
||||
val isOnCurrentWeek = state !is WeekUiState.Success ||
|
||||
(today >= weekStart && today <= weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY))
|
||||
|
||||
// Straight to the pager: a tap mid-fling must still land, and the anchor
|
||||
// hasn't caught up with the fling yet to tell the difference.
|
||||
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||
slideDir = if (target < weekStart) -1 else 1
|
||||
viewModel.goToDate(target)
|
||||
val page = weekPageFor(target, firstDay)
|
||||
scope.launch { pagerState.jumpToPage(page, reduceMotion, pageSpec) }
|
||||
}
|
||||
val jumpToToday = { jumpToDate(today) }
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -261,7 +283,7 @@ fun WeekScreen(
|
||||
topBar = {
|
||||
WeekTopBar(
|
||||
weekStart = weekStart,
|
||||
currentYear = currentYear,
|
||||
currentYear = today.year,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
@@ -279,8 +301,6 @@ fun WeekScreen(
|
||||
onToday = jumpToToday,
|
||||
onCreate = {
|
||||
// Anchor on today when it's in view, else the week's first day.
|
||||
val today = Clock.System.now()
|
||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
onCreateEvent(if (isOnCurrentWeek) today else weekStart, null)
|
||||
},
|
||||
)
|
||||
@@ -289,10 +309,10 @@ fun WeekScreen(
|
||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||
WeekContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
pagerState = pagerState,
|
||||
firstDay = firstDay,
|
||||
week = viewModel::week,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||
@@ -308,20 +328,18 @@ fun WeekScreen(
|
||||
@Composable
|
||||
private fun WeekContent(
|
||||
state: WeekUiState,
|
||||
slideDir: Int,
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
pagerState: PagerState,
|
||||
firstDay: DayOfWeek,
|
||||
week: (LocalDate) -> StateFlow<WeekUiState>,
|
||||
onRetry: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
|
||||
// Hoisted above the per-week AnimatedContent so the vertical scroll position
|
||||
// Shared by every page and the gutter, so the vertical scroll position
|
||||
// survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on
|
||||
// noon once, on first entry into the week view (i.e. when arriving from the
|
||||
// month/day view), not on every swipe.
|
||||
@@ -333,62 +351,47 @@ private fun WeekContent(
|
||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||
}
|
||||
|
||||
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
||||
// week during a swipe, so the strip slides along but never jumps in height;
|
||||
// it just springs smoothly from the old to the new size.
|
||||
val targetAllDayHeight = (state as? WeekUiState.Success)?.allDayStripHeight() ?: 0.dp
|
||||
val allDayHeight by animateDpAsState(
|
||||
targetValue = targetAllDayHeight,
|
||||
label = "all-day-strip-height",
|
||||
)
|
||||
|
||||
// Whole-page horizontal swipe. It sits one level above the timeline's
|
||||
// vertical scroll: a horizontal drag only crosses *this* detector's slop,
|
||||
// while a vertical drag is consumed by the inner scroll first — so the two
|
||||
// gestures coexist without fighting.
|
||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||
|
||||
// Above the AnimatedContent: a page change mid-drag would strand the
|
||||
// floating block inside the outgoing page.
|
||||
// Above the pager: 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() }
|
||||
val onDrop: (TimelineDrop) -> Unit = { drop ->
|
||||
move?.move(
|
||||
MoveRequest(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
// Loading and failure concern the provider as a whole, so they stand in
|
||||
// for the pager; a page still loading its own week handles that itself.
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
when (s) {
|
||||
is WeekUiState.Success -> "success-${s.weekStart}"
|
||||
is WeekUiState.Failure -> "failure-${s.reason}"
|
||||
WeekUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "week-transition",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentKey = { it::class },
|
||||
transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) },
|
||||
label = "week-state",
|
||||
) { s ->
|
||||
when (s) {
|
||||
WeekUiState.Loading -> WeekLoading()
|
||||
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is WeekUiState.Success -> WeekSuccess(
|
||||
state = s,
|
||||
is WeekUiState.Success -> WeekPager(
|
||||
pagerState = pagerState,
|
||||
firstDay = firstDay,
|
||||
week = week,
|
||||
today = s.today,
|
||||
initialStripHeight = s.allDayStripHeight(),
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
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)),
|
||||
),
|
||||
)
|
||||
},
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -396,6 +399,123 @@ private fun WeekContent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The weeks as pages that follow the finger and snap once a swipe passes half a
|
||||
* page or is flung (#131), beside a gutter that stays put.
|
||||
*/
|
||||
@Composable
|
||||
private fun WeekPager(
|
||||
pagerState: PagerState,
|
||||
firstDay: DayOfWeek,
|
||||
week: (LocalDate) -> StateFlow<WeekUiState>,
|
||||
today: LocalDate,
|
||||
initialStripHeight: Dp,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
|
||||
val allDayHeight = pagedStripHeight(pagerState, stripHeights, initialStripHeight)
|
||||
WeekFrame(
|
||||
weekStart = weekStartForPage(pagerState.currentPage, firstDay),
|
||||
allDayHeight = allDayHeight,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
// The weeks either side are composed ahead, so a swipe lands on a
|
||||
// loaded week rather than watching it fill in.
|
||||
beyondViewportPageCount = 1,
|
||||
// A held block moves within its week; the page stays put under it.
|
||||
userScrollEnabled = !dragController.isDragging,
|
||||
flingBehavior = PagerDefaults.flingBehavior(
|
||||
state = pagerState,
|
||||
snapAnimationSpec = rememberCalendarPageSpec(),
|
||||
),
|
||||
) { page ->
|
||||
val start = weekStartForPage(page, firstDay)
|
||||
val pageState by remember(start) { week(start) }.collectAsStateWithLifecycle()
|
||||
val loaded = pageState as? WeekUiState.Success
|
||||
// Until its week arrives a page shows the week's frame without events.
|
||||
val empty = remember(start, today) { emptyWeek(start, today) }
|
||||
val stripHeight = loaded?.allDayStripHeight()
|
||||
DisposableEffect(page, stripHeight) {
|
||||
if (stripHeight != null) stripHeights[page] = stripHeight
|
||||
onDispose { stripHeights.remove(page) }
|
||||
}
|
||||
WeekPage(
|
||||
state = loaded ?: empty,
|
||||
allDayHeight = allDayHeight,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
active = page == pagerState.currentPage,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A week with no events yet, for a page whose week is still loading. */
|
||||
private fun emptyWeek(start: LocalDate, today: LocalDate): WeekUiState.Success {
|
||||
val days = (0 until 7).map { start.plus(it, kotlinx.datetime.DateTimeUnit.DAY) }
|
||||
return WeekUiState.Success(
|
||||
weekStart = start,
|
||||
today = today,
|
||||
days = days,
|
||||
allDaySpans = emptyList(),
|
||||
timedByDay = emptyMap(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What stays put while the weeks swipe: the week-number badge and the hour
|
||||
* gutter, with [pages] filling the rest.
|
||||
*/
|
||||
@Composable
|
||||
private fun WeekFrame(
|
||||
weekStart: LocalDate,
|
||||
allDayHeight: Dp,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
pages: @Composable () -> Unit,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.width(GUTTER_WIDTH).fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
WeekNumberCell(weekStart)
|
||||
Spacer(Modifier.height(allDayHeight))
|
||||
}
|
||||
Spacer(Modifier.height(TIMELINE_TOP_GAP))
|
||||
// Resolves the hour height off the same viewport height the pages'
|
||||
// timelines have, so labels and columns agree.
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val zoom = LocalTimelineZoom.current
|
||||
val hourHeight = zoom.scale.hourHeight(maxHeight)
|
||||
HourGutter(
|
||||
scrollState = scrollState,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
modifier = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom),
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f).fillMaxHeight()) { pages() }
|
||||
}
|
||||
}
|
||||
|
||||
/** A single week in its frame, without the pager — for the Settings preview. */
|
||||
@Composable
|
||||
internal fun WeekSuccess(
|
||||
state: WeekUiState.Success,
|
||||
@@ -406,6 +526,44 @@ internal fun WeekSuccess(
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
WeekFrame(
|
||||
weekStart = state.weekStart,
|
||||
allDayHeight = allDayHeight,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
) {
|
||||
WeekPage(
|
||||
state = state,
|
||||
allDayHeight = allDayHeight,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
active = true,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One week's page: header, all-day strip and day columns.
|
||||
*
|
||||
* @param active whether this is the page on screen, the only one that may
|
||||
* publish the drag geometry.
|
||||
*/
|
||||
@Composable
|
||||
private fun WeekPage(
|
||||
state: WeekUiState.Success,
|
||||
allDayHeight: Dp,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
active: Boolean,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
@@ -422,13 +580,12 @@ internal fun WeekSuccess(
|
||||
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
|
||||
}
|
||||
}
|
||||
// Breathing room between the top section and the scrolling timeline
|
||||
// below.
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Spacer(Modifier.height(TIMELINE_TOP_GAP))
|
||||
Timeline(
|
||||
state = state,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
active = active,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
@@ -503,24 +660,11 @@ private fun WeekDayHeader(
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
val weekStart = days.first()
|
||||
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp, bottom = 8.dp, end = TIMELINE_CONTENT_END_INSET),
|
||||
.padding(top = HEADER_TOP_PADDING, bottom = HEADER_BOTTOM_PADDING, end = TIMELINE_CONTENT_END_INSET),
|
||||
) {
|
||||
// Mirror the day-column layout (empty weekday line + spacer) so the
|
||||
// badge lines up vertically with the date numbers. The start inset centres
|
||||
// the badge on the top bar's hamburger (see GUTTER_CONTENT_START_INSET).
|
||||
Column(
|
||||
modifier = Modifier.width(GUTTER_WIDTH).padding(start = GUTTER_CONTENT_START_INSET),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(text = " ", style = MaterialTheme.typography.labelSmall)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
WeekNumberBadge(weekNumber = weekNumber)
|
||||
}
|
||||
days.forEach { date ->
|
||||
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
|
||||
val isToday = date == today
|
||||
@@ -531,16 +675,19 @@ private fun WeekDayHeader(
|
||||
.clickable { onOpenDay(date) },
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// One line, as the gutter cell beside it assumes (see WeekNumberCell).
|
||||
Text(
|
||||
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Clip,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
// Always reserve the 28dp circle slot so the header height is
|
||||
// Always reserve the circle slot so the header height is
|
||||
// identical whether or not the week contains today.
|
||||
Box(
|
||||
modifier = Modifier.size(28.dp),
|
||||
modifier = Modifier.size(DATE_SLOT_SIZE),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isToday) {
|
||||
@@ -570,8 +717,35 @@ private fun WeekDayHeader(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The header's gutter cell, laid out like a day's (empty weekday line, spacer,
|
||||
* date slot) so the badge lines up with the date numbers and the cell is exactly
|
||||
* as tall as the header beside it. The start inset centres the badge on the top
|
||||
* bar's hamburger (see GUTTER_CONTENT_START_INSET).
|
||||
*/
|
||||
@Composable
|
||||
private fun WeekNumberCell(weekStart: LocalDate) {
|
||||
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = GUTTER_CONTENT_START_INSET,
|
||||
top = HEADER_TOP_PADDING,
|
||||
bottom = HEADER_BOTTOM_PADDING,
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(text = " ", style = MaterialTheme.typography.labelSmall)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Box(modifier = Modifier.height(DATE_SLOT_SIZE), contentAlignment = Alignment.TopCenter) {
|
||||
WeekNumberBadge(weekNumber = weekNumber)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Calendar-week badge shown in the header gutter, deliberately set apart with a
|
||||
* filled box and bold number. */
|
||||
* filled box and bold number — at the month grid's size, so the two agree (#213). */
|
||||
@Composable
|
||||
private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
|
||||
val label = stringResource(R.string.week_number_label)
|
||||
@@ -583,9 +757,9 @@ private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
|
||||
) {
|
||||
Text(
|
||||
text = weekNumber.toString(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -610,8 +784,6 @@ private fun AllDayStrip(
|
||||
end = TIMELINE_CONTENT_END_INSET,
|
||||
),
|
||||
) {
|
||||
// Keep the gutter-width offset so the bars line up with the day columns.
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
// Span bars are positioned absolutely so a multi-day event is one
|
||||
// connected bar across columns rather than a chip per day. clipToBounds
|
||||
// keeps bars from spilling out while the height animates.
|
||||
@@ -664,7 +836,7 @@ private fun AllDayBar(
|
||||
val titleOverflow = eventTitleOverflow()
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
style = MaterialTheme.typography.labelSmall.trimmedLines(),
|
||||
maxLines = 1,
|
||||
overflow = titleOverflow.overflow,
|
||||
softWrap = titleOverflow.softWrap,
|
||||
@@ -680,6 +852,7 @@ private fun Timeline(
|
||||
state: WeekUiState.Success,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
active: Boolean,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
@@ -701,70 +874,61 @@ private fun Timeline(
|
||||
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.
|
||||
// The pinch sits above the scroll viewport: it has to outrank the
|
||||
// vertical scroll, and it does that by watching the initial pass, which
|
||||
// only reaches it if it is the viewport's ancestor.
|
||||
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
|
||||
// Gutter and day columns 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 (not just at the
|
||||
// day's start/end).
|
||||
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
||||
// Hour gutter (scrolls in sync with the day columns). Same start inset
|
||||
// as the header badge so the labels sit under it and on the hamburger.
|
||||
HourGutter(
|
||||
scrollState = scrollState,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
)
|
||||
// Day columns: rounded, clipped scroll viewport (permanent corners).
|
||||
Box(
|
||||
// Scrolls on the same state as the gutter and the other pages, so they
|
||||
// all stay aligned. A static, rounded-clipped window — the content
|
||||
// scrolls inside it, so the soft corners are permanent at any scroll
|
||||
// position (not just at the day's start/end).
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(pinch)
|
||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.verticalScroll(scrollState)
|
||||
.onGloballyPositioned { if (active) dragController.geometry.viewport = it },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.verticalScroll(scrollState)
|
||||
.onGloballyPositioned { dragController.geometry.viewport = it },
|
||||
.fillMaxWidth()
|
||||
.height(totalHeight)
|
||||
// The scrolling content itself, so its root position
|
||||
// already folds in the scroll offset. Only the page on
|
||||
// screen publishes it: the ones either side are laid out too.
|
||||
.onGloballyPositioned { coords ->
|
||||
if (!active) return@onGloballyPositioned
|
||||
val gap = with(density) { COLUMN_GAP.toPx() }
|
||||
dragController.geometry.let {
|
||||
it.grid = coords
|
||||
it.scroll = scrollState
|
||||
it.hourPx = with(density) { hourHeight.toPx() }
|
||||
it.blockInsetPx = blockInsetPx
|
||||
it.columnGapPx = gap
|
||||
it.columnWidthPx = (coords.size.width + gap) / state.days.size
|
||||
it.days = state.days
|
||||
it.isRtl = isRtl
|
||||
}
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(totalHeight)
|
||||
// The scrolling content itself, so its root position
|
||||
// already folds in the scroll offset.
|
||||
.onGloballyPositioned { coords ->
|
||||
val gap = with(density) { COLUMN_GAP.toPx() }
|
||||
dragController.geometry.let {
|
||||
it.grid = coords
|
||||
it.scroll = scrollState
|
||||
it.hourPx = with(density) { hourHeight.toPx() }
|
||||
it.blockInsetPx = blockInsetPx
|
||||
it.columnGapPx = gap
|
||||
it.columnWidthPx = (coords.size.width + gap) / state.days.size
|
||||
it.days = state.days
|
||||
it.isRtl = isRtl
|
||||
}
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP),
|
||||
) {
|
||||
state.days.forEach { day ->
|
||||
DayColumnCard(
|
||||
blocks = state.timedByDay[day].orEmpty(),
|
||||
dark = dark,
|
||||
date = day,
|
||||
today = state.today,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
state.days.forEach { day ->
|
||||
DayColumnCard(
|
||||
blocks = state.timedByDay[day].orEmpty(),
|
||||
dark = dark,
|
||||
date = day,
|
||||
today = state.today,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -909,13 +1073,7 @@ private fun EventBlock(
|
||||
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()
|
||||
}
|
||||
val available = height - BLOCK_TEXT_INSET * 2
|
||||
val metrics = rememberBlockTextMetrics(height)
|
||||
// Only full-width (non-overlapping) blocks that are tall enough show the
|
||||
// time. On narrow overlapping columns we drop it so the title can wrap to
|
||||
// fill the whole block, mirroring Google Calendar — and a block that cannot
|
||||
@@ -923,19 +1081,19 @@ private fun EventBlock(
|
||||
// its own: 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 showTime = block.laneCount == 1 &&
|
||||
available >= titleLineHeight + timeLineHeight
|
||||
metrics.available >= metrics.titleLine + metrics.timeLine
|
||||
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
||||
// A short block drops the title rather than serving a horizontally sliced
|
||||
// one: half a letter reads as a rendering fault, while a bare colour chip
|
||||
// reads as what it is — an event too brief to label. Tap still opens it, and
|
||||
// the semantics description carries the full title either way.
|
||||
val showTitle = available >= titleLineHeight
|
||||
val showTitle = metrics.fitsTitle
|
||||
// The title is served first, out of everything the block has left once the
|
||||
// time is down to one line — but only takes the lines it will actually use,
|
||||
// and only wraps at all once a line is wide enough to hold more than a
|
||||
// syllable. Below that the extra lines just stack fragments of the word.
|
||||
val contentHeight = available - if (showTime) timeLineHeight else 0.dp
|
||||
val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||
val contentHeight = metrics.available - if (showTime) metrics.timeLine else 0.dp
|
||||
val titleBudget = metrics.titleBudget(contentHeight).coerceAtLeast(1)
|
||||
val paint = eventPaint(block.event, dark)
|
||||
// Every line the height affords, however narrow the lane: two events side by
|
||||
// side leave columns well under a word wide, and cutting the title to one
|
||||
@@ -948,8 +1106,8 @@ private fun EventBlock(
|
||||
textWidth = textWidth,
|
||||
max = titleBudget,
|
||||
)
|
||||
val spare = available - titleLineHeight * titleMaxLines -
|
||||
if (showTime) timeLineHeight else 0.dp
|
||||
val spare = metrics.available - metrics.titleHeight(titleMaxLines) -
|
||||
if (showTime) metrics.timeLine else 0.dp
|
||||
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||
@@ -991,7 +1149,7 @@ private fun EventBlock(
|
||||
// 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)
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
|
||||
.semantics {
|
||||
contentDescription = "$title, $timeLabel"
|
||||
if (moveAction != null) customActions = listOf(moveAction)
|
||||
|
||||
@@ -12,6 +12,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -20,6 +21,7 @@ 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.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -30,6 +32,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
|
||||
@@ -38,9 +41,13 @@ 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 week [WeekViewModel.week] keeps other weeks cached. */
|
||||
private const val WEEK_CACHE_DAYS = 28
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class WeekViewModel @Inject constructor(
|
||||
@@ -64,7 +71,7 @@ class WeekViewModel @Inject constructor(
|
||||
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
|
||||
val firstDayOfWeek: StateFlow<DayOfWeek> = settingsPrefs.weekStart
|
||||
.map { it.resolveFirstDay(locale) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
@@ -73,55 +80,65 @@ class WeekViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
// 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.
|
||||
// start is derived against [firstDayOfWeek], so changing the first-day
|
||||
// preference re-frames the same week instead of jumping.
|
||||
private val _anchor = MutableStateFlow(todayDate)
|
||||
|
||||
val weekStartDate: StateFlow<LocalDate> =
|
||||
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
|
||||
/** The pager page the anchor's week sits on. */
|
||||
val anchorPage: StateFlow<Int> =
|
||||
combine(_anchor, firstDayOfWeek) { anchor, ws -> weekPageFor(anchor, ws) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = todayDate.startOfWeek(DayOfWeek.MONDAY),
|
||||
initialValue = weekPageFor(todayDate, DayOfWeek.MONDAY),
|
||||
)
|
||||
|
||||
/**
|
||||
* The anchor week's state. Once loaded it never falls back to [WeekUiState.Loading]:
|
||||
* moving the anchor keeps the last result until the new week arrives, since
|
||||
* the pages draw their own weeks and this only gates the failure screen.
|
||||
*/
|
||||
val state: StateFlow<WeekUiState> =
|
||||
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
|
||||
combine(_anchor, firstDayOfWeek) { anchor, ws -> anchor.startOfWeek(ws) }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { start ->
|
||||
val range = weekRange(start, zone)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
) { calendars, instances ->
|
||||
buildState(start, calendars, instances)
|
||||
}
|
||||
}
|
||||
.flatMapLatest { start -> week(start).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 weeks = PageStateCache<LocalDate, 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 -> abs(cached.daysUntil(requested)) <= WEEK_CACHE_DAYS },
|
||||
) { start ->
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(weekRange(start, zone)),
|
||||
) { calendars, instances ->
|
||||
buildState(start, calendars, instances)
|
||||
}
|
||||
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
}
|
||||
|
||||
fun goToNext() {
|
||||
_anchor.value = _anchor.value.plus(7, DateTimeUnit.DAY)
|
||||
/** The week starting at [start], shared between its pager page and [state]. Main thread only. */
|
||||
fun week(start: LocalDate): StateFlow<WeekUiState> = weeks.get(start)
|
||||
|
||||
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
|
||||
fun onPageSettled(page: Int) {
|
||||
val ws = firstDayOfWeek.value
|
||||
if (weekPageFor(_anchor.value, ws) != page) _anchor.value = weekStartForPage(page, ws)
|
||||
}
|
||||
|
||||
fun goToToday() {
|
||||
_anchor.value = todayDate
|
||||
}
|
||||
|
||||
/** Jump to the week containing [date] (drawer jump-to-date). */
|
||||
fun goToDate(date: LocalDate) {
|
||||
_anchor.value = date
|
||||
}
|
||||
|
||||
private fun buildState(
|
||||
start: LocalDate,
|
||||
calendars: List<CalendarSource>,
|
||||
@@ -167,7 +184,7 @@ internal fun layoutAllDay(
|
||||
// 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 ->
|
||||
|
||||
@@ -655,12 +655,9 @@
|
||||
<string name="crash_dialog_message">Апошнім разам %1$s нечакана закрылася. Вы можаце дапамагчы выправіць гэта, адправіўшы справаздачу аб праблеме. Яна застанецца на вашай прыладзе, пакуль вы не вырашыце падзяліцца ёю, і ня ўтрымлівае асабістых даных або зьмесціва календара — толькі тэхнічныя падрабязнасьці, паданыя ніжэй.</string>
|
||||
<string name="crash_dialog_report">Справаздача</string>
|
||||
<string name="crash_dialog_dismiss">Ня зараз</string>
|
||||
<string name="crash_report_issue_title">Справаздача аб краху</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="crash_report_body_template">Дзякуй за справаздачу аб краху ў %1$s. Дадайце ўсё, што памятаеце пра тое, што рабілі, а затым адпраўце.\n\n### Што здарылася\n\n\n### Справаздача аб краху\n%2$s\n</string>
|
||||
<string name="crash_report_body_paste">_(Справаздача задоўгая для гэтай спасылкі — устаўце яе з буфэра абмену сюды.)_</string>
|
||||
<string name="special_dates_calendar_birthday">Дні нараджэньня</string>
|
||||
<string name="special_dates_calendar_anniversary">Юбілейныя даты</string>
|
||||
<string name="special_dates_calendar_custom">Асаблівыя даты</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>
|
||||
@@ -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>
|
||||
@@ -190,7 +190,7 @@
|
||||
<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í připomínka</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>
|
||||
@@ -409,7 +409,7 @@
|
||||
<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_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>
|
||||
@@ -430,12 +430,12 @@
|
||||
<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">%dden</item>
|
||||
<item quantity="few">%ddny</item>
|
||||
<item quantity="many">%ddnů</item>
|
||||
<item quantity="other">%ddnů</item>
|
||||
<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">Pohledy</string>
|
||||
<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>
|
||||
@@ -485,4 +485,212 @@
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -11,12 +11,20 @@
|
||||
<string name="state_retry">Retry</string>
|
||||
<string name="state_failure_unknown">Something went wrong.</string>
|
||||
<string name="state_failure_permission">Calendar access is required.</string>
|
||||
<string name="state_failure_permission_body">Calendula reads your events straight from the system calendar, so it needs calendar access to show anything.</string>
|
||||
<string name="state_failure_permission_action">Grant access</string>
|
||||
<string name="state_failure_no_calendars">No calendars configured.</string>
|
||||
<string name="state_failure_no_calendars_body">Add a calendar account to this device and its calendars show up here.</string>
|
||||
<string name="state_failure_no_calendars_action">Open system calendar settings</string>
|
||||
<string name="state_failure_all_hidden">All your calendars are switched off.</string>
|
||||
<string name="state_failure_all_hidden_body">Switch at least one calendar back on to see its events.</string>
|
||||
<string name="state_failure_all_hidden_action">Manage calendars</string>
|
||||
<string name="state_failure_no_import_target">No calendar can take new events.</string>
|
||||
<string name="state_failure_no_import_target_body">The calendars that are switched on are read-only, managed by another app, or not synced to this device. Switch on a calendar that can take events, or add a local one.</string>
|
||||
<string name="state_failure_event_not_found">This event is no longer there.</string>
|
||||
<string name="state_failure_event_not_found_body">It may have been deleted, or moved to a calendar that is switched off.</string>
|
||||
<string name="state_failure_provider">Could not read the calendar.</string>
|
||||
<string name="state_failure_provider_body">The system calendar did not answer. Try again in a moment.</string>
|
||||
|
||||
<!-- Long-press a field to copy it (#195) -->
|
||||
<string name="field_copy_action">Copy</string>
|
||||
@@ -282,6 +290,9 @@
|
||||
<!-- Shared event strings -->
|
||||
<string name="event_untitled">(No title)</string>
|
||||
|
||||
<!-- Stands in for a calendar the provider gave no name (#329) -->
|
||||
<string name="calendar_unnamed">(No name)</string>
|
||||
|
||||
<!-- First-launch wizard (#163) -->
|
||||
<string name="onboarding_step_counter">Step %1$d of %2$d</string>
|
||||
<string name="onboarding_backup_title">Your events live only here</string>
|
||||
@@ -290,6 +301,26 @@
|
||||
<string name="onboarding_backup_benefit_folder_body">Backups are plain .ics files — put them somewhere that syncs, or on an SD card.</string>
|
||||
<string name="onboarding_backup_benefit_daily_title">Once a day, by itself</string>
|
||||
<string name="onboarding_backup_benefit_daily_body">Calendula exports your local calendars in the background. Change how often in Settings.</string>
|
||||
<string name="onboarding_calendars_title">Somewhere to put your events</string>
|
||||
<string name="onboarding_calendars_body">This device has no calendar yet. Calendula writes to the calendars already on your phone — it needs at least one.</string>
|
||||
<string name="onboarding_calendars_local_button">Create a calendar on this device</string>
|
||||
<string name="onboarding_calendars_caldav_button">Sync with a CalDAV server</string>
|
||||
<string name="onboarding_calendars_skip_button">Not now</string>
|
||||
<string name="onboarding_calendars_benefit_local_title">Stays on this phone</string>
|
||||
<string name="onboarding_calendars_benefit_local_body">A device calendar syncs nowhere, so back it up if the events matter.</string>
|
||||
<string name="onboarding_calendars_benefit_sync_title">Or sync from an account</string>
|
||||
<string name="onboarding_calendars_benefit_sync_body">Calendula shows what your accounts already sync. CalDAV servers need DAVx\u2075 to do the syncing.</string>
|
||||
<string name="onboarding_calendars_created_title">“%1$s” is ready</string>
|
||||
<string name="onboarding_calendars_created_body">It lives on this device and new events go into it unless you pick another calendar.</string>
|
||||
<string name="onboarding_calendars_created_more_title">Room for more</string>
|
||||
<string name="onboarding_calendars_created_more_body">Settings → Calendars makes as many as you need, and renames or recolours the ones you have.</string>
|
||||
<string name="onboarding_calendars_created_continue">Continue</string>
|
||||
<string name="onboarding_hidden_title">Your calendars are switched off</string>
|
||||
<string name="onboarding_hidden_body">Calendula shows the calendars this phone has switched on, and right now that is none of them. Switch on the ones you want to see — your other calendar apps show them again too.</string>
|
||||
<string name="onboarding_hidden_switch_all_button">Switch them all on</string>
|
||||
<string name="onboarding_hidden_skip_button">Not now</string>
|
||||
<string name="onboarding_hidden_failed">Couldn\'t switch that on. Settings → Calendars has the same switches.</string>
|
||||
<string name="onboarding_calendars_create_failed">Couldn\'t create the calendar. Your phone\'s calendar storage refused it.</string>
|
||||
<string name="onboarding_backup_enable_button">Choose folder and back up</string>
|
||||
<string name="onboarding_backup_skip_button">Not now</string>
|
||||
<string name="onboarding_view_title">What should open first?</string>
|
||||
@@ -665,7 +696,20 @@
|
||||
<string name="calendars_edit_title">Edit calendar</string>
|
||||
<string name="calendars_name_label">Name</string>
|
||||
<string name="calendars_color_label">Color</string>
|
||||
<string name="calendars_description_label">Description</string>
|
||||
<string name="calendars_description_hint">Add a description</string>
|
||||
<string name="color_name_mauve">Mauve</string>
|
||||
<string name="color_name_red">Red</string>
|
||||
<string name="color_name_orange">Orange</string>
|
||||
<string name="color_name_amber">Amber</string>
|
||||
<string name="color_name_olive">Olive</string>
|
||||
<string name="color_name_green">Green</string>
|
||||
<string name="color_name_teal">Teal</string>
|
||||
<string name="color_name_cyan">Cyan</string>
|
||||
<string name="color_name_blue">Blue</string>
|
||||
<string name="color_name_indigo">Indigo</string>
|
||||
<string name="color_name_purple">Purple</string>
|
||||
<string name="color_name_pink">Pink</string>
|
||||
<string name="calendars_delete_confirm_title">Delete calendar?</string>
|
||||
<string name="calendars_delete_confirm_message">\"%1$s\" and all of its events will be permanently removed from this device.</string>
|
||||
<string name="calendars_write_error">Couldn\'t save the change.</string>
|
||||
@@ -679,6 +723,7 @@
|
||||
<string name="calendars_restore_header">Restore</string>
|
||||
<string name="calendars_restore_action">Restore from .ics file</string>
|
||||
<string name="calendars_restore_hint">Import events from a backup or another calendar app.</string>
|
||||
<string name="calendars_restore_unavailable">No calendar can take the imported events yet.</string>
|
||||
<string name="calendars_auto_backup">Automatic backup</string>
|
||||
<string name="calendars_auto_backup_hint">Periodically export your local calendars to a folder as an .ics file.</string>
|
||||
<string name="calendars_auto_backup_folder">Backup folder</string>
|
||||
|
||||
@@ -64,9 +64,9 @@ class CalendarMapperTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null displayName falls back to placeholder`() {
|
||||
fun `null displayName is left blank for the UI to fill in`() {
|
||||
val src = reader(displayName = null).toCalendarSource()
|
||||
assertThat(src.displayName).isEqualTo(Fallbacks.UNNAMED_CALENDAR)
|
||||
assertThat(src.displayName).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -61,15 +61,15 @@ class InstanceMapperTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null title falls back to placeholder`() {
|
||||
fun `null title is left blank for the UI to fill in`() {
|
||||
val inst = reader(title = null).toEventInstance()
|
||||
assertThat(inst!!.title).isEqualTo(Fallbacks.UNTITLED_EVENT)
|
||||
assertThat(inst!!.title).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty title falls back to placeholder`() {
|
||||
fun `empty title is left blank for the UI to fill in`() {
|
||||
val inst = reader(title = "").toEventInstance()
|
||||
assertThat(inst!!.title).isEqualTo(Fallbacks.UNTITLED_EVENT)
|
||||
assertThat(inst!!.title).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -242,8 +242,8 @@ class EventSearchTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an untitled event's placeholder is not something the query can match`() {
|
||||
val untitled = candidate("(Ohne Titel)", description = "Titel folgt noch").copy(title = null)
|
||||
fun `an untitled event's blank title is not something the query can match`() {
|
||||
val untitled = candidate("", description = "Titel folgt noch").copy(title = null)
|
||||
|
||||
val results = search(untitled, query = "titel")
|
||||
|
||||
|
||||
@@ -36,12 +36,13 @@ class ModelsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FailureReason enum has all six variants`() {
|
||||
fun `FailureReason enum has all seven variants`() {
|
||||
assertThat(FailureReason.values().toSet()).isEqualTo(
|
||||
setOf(
|
||||
FailureReason.PermissionRevoked,
|
||||
FailureReason.NoCalendarsConfigured,
|
||||
FailureReason.AllCalendarsHidden,
|
||||
FailureReason.NoImportTarget,
|
||||
FailureReason.ProviderUnavailable,
|
||||
FailureReason.EventNotFound,
|
||||
FailureReason.Unknown,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import kotlinx.datetime.TimeZone
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Reading a Google Calendar export (Codeberg #304), whose dialect the parser had
|
||||
* never been held to: `VTIMEZONE` blocks with `X-LIC-LOCATION`, `TZID`-qualified
|
||||
* `DTSTART`/`EXDATE`, folded `DESCRIPTION` and `ATTENDEE` lines, `RECURRENCE-ID`
|
||||
* overrides written as separate `VEVENT`s, and `@google.com` UIDs.
|
||||
*
|
||||
* The fixture is a trimmed copy of what "Settings → Import & export → Export"
|
||||
* produces, CRLF and all. Note that Google hands that export out as a **zip**
|
||||
* containing one `.ics` per calendar — the file here is one member of it.
|
||||
*/
|
||||
class IcsGoogleImportTest {
|
||||
|
||||
private val parser = IcsParser(TimeZone.of("Europe/Berlin"))
|
||||
|
||||
private val result: IcsParseResult = parser.parse(
|
||||
checkNotNull(
|
||||
javaClass.classLoader?.getResourceAsStream("ics/google-calendar-export.ics"),
|
||||
).use { it.readBytes().toString(Charsets.UTF_8) },
|
||||
)
|
||||
|
||||
private fun event(summary: String) = result.events.single { it.summary == summary }
|
||||
|
||||
@Test
|
||||
fun `every master event imports and the VTIMEZONE block is not mistaken for one`() {
|
||||
assertThat(result.events.map { it.summary })
|
||||
.containsExactly("Team standup", "Day off", "Christmas break")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `X-WR-CALNAME names the calendar for all of them`() {
|
||||
assertThat(result.events.map { it.calendarName }.distinct())
|
||||
.containsExactly("jane@example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a TZID-qualified start resolves against the device tz database`() {
|
||||
val standup = event("Team standup")
|
||||
assertThat(standup.isAllDay).isFalse()
|
||||
assertThat(standup.zoneId).isEqualTo("Europe/Berlin")
|
||||
// 10:00 CEST is 08:00 UTC.
|
||||
assertThat(standup.start.toString()).isEqualTo("2026-09-15T08:00:00Z")
|
||||
assertThat((standup.end - standup.start).inWholeMinutes).isEqualTo(90)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the recurrence rule and its TZID-qualified EXDATE survive`() {
|
||||
val standup = event("Team standup")
|
||||
assertThat(standup.recurrenceRule).isEqualTo("FREQ=WEEKLY;BYDAY=TU")
|
||||
assertThat(standup.exDates).containsExactly("20260929T080000Z")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the moved occurrence is skipped rather than imported as a duplicate`() {
|
||||
// Google writes a RECURRENCE-ID override as its own VEVENT carrying the
|
||||
// master's UID. Importing it would put a second "standup" in the
|
||||
// calendar; Calendula models no overrides, so it is reported instead.
|
||||
assertThat(result.events.map { it.summary }).doesNotContain("Team standup (moved)")
|
||||
assertThat(result.warnings).contains(IcsParseWarning.ModifiedOccurrenceSkipped)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a folded DESCRIPTION is unfolded and unescaped`() {
|
||||
assertThat(event("Team standup").description).isEqualTo(
|
||||
"Weekly sync with the team.\nAgenda lives in the shared doc, see the link below.",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an escaped comma in LOCATION comes back as a comma`() {
|
||||
assertThat(event("Team standup").location).isEqualTo("Meeting room 2, 3rd floor")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attendees are reported rather than silently dropped`() {
|
||||
assertThat(result.warnings).contains(IcsParseWarning.AttendeesIgnored)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed VALARM becomes its lead time in minutes`() {
|
||||
assertThat(event("Team standup").semanticReminderMinutes()).containsExactly(30)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day event keeps the single day the file gives it`() {
|
||||
val dayOff = event("Day off")
|
||||
assertThat(dayOff.isAllDay).isTrue()
|
||||
assertThat((dayOff.end - dayOff.start).inWholeDays).isEqualTo(1)
|
||||
assertThat(dayOff.availability).isEqualTo(Availability.Free)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day alarm comes back as whole days before`() {
|
||||
// Google writes an all-day reminder as a whole-day offset from the
|
||||
// event's UTC midnight; a day has to survive as a day.
|
||||
assertThat(event("Day off").semanticReminderMinutes()).containsExactly(1440)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-day all-day event keeps its exclusive DTEND span`() {
|
||||
val christmas = event("Christmas break")
|
||||
assertThat(christmas.isAllDay).isTrue()
|
||||
assertThat((christmas.end - christmas.start).inWholeDays).isEqualTo(3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no recurrence rule is repaired - Google writes them well-formed`() {
|
||||
assertThat(result.warnings).doesNotContain(IcsParseWarning.RecurrenceRuleRepaired)
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import kotlinx.datetime.TimeZone
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* A component whose `END:` line never arrives (a download cut short, a producer
|
||||
* that dropped a line). `indexOfEnd` falls back to the end of the file, so the
|
||||
* block is read as if it had been closed.
|
||||
*/
|
||||
class IcsUnterminatedComponentTest {
|
||||
|
||||
private val parser = IcsParser(TimeZone.of("Europe/Berlin"))
|
||||
|
||||
@Test
|
||||
fun `file truncated mid-property keeps the event it had started`() {
|
||||
val result = parser.parse(
|
||||
"""
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:truncated-1@fixture
|
||||
SUMMARY:Cut off mid-file
|
||||
DTSTART:20260924T100000Z
|
||||
DTEND:20260924T1
|
||||
""".trimIndent(),
|
||||
)
|
||||
assertEquals(1, result.events.size)
|
||||
val event = result.events.single()
|
||||
assertEquals("Cut off mid-file", event.summary)
|
||||
// The truncated DTEND does not parse, so the event is zero length.
|
||||
assertEquals(event.start, event.end)
|
||||
assertEquals(emptySet<IcsParseWarning>(), result.warnings)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing END between two events does not merge them`() {
|
||||
val result = parser.parse(
|
||||
"""
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:first@fixture
|
||||
SUMMARY:First event
|
||||
DTSTART:20260924T100000Z
|
||||
DTEND:20260924T110000Z
|
||||
BEGIN:VEVENT
|
||||
UID:second@fixture
|
||||
SUMMARY:Second event
|
||||
DTSTART:20260925T100000Z
|
||||
DTEND:20260925T110000Z
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
""".trimIndent(),
|
||||
)
|
||||
assertEquals(
|
||||
listOf("First event", "Second event"),
|
||||
result.events.map { it.summary },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("first@fixture", "second@fixture"),
|
||||
result.events.map { it.uid },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/** What the Backup & restore screen offers for a given calendar list (#304). */
|
||||
class BackupUiStateTest {
|
||||
|
||||
private fun cal(
|
||||
id: Long,
|
||||
local: Boolean = false,
|
||||
writable: Boolean = true,
|
||||
visible: Boolean = true,
|
||||
managed: Boolean = false,
|
||||
syncs: Boolean = true,
|
||||
) = CalendarSource(
|
||||
id = id,
|
||||
displayName = "Calendar $id",
|
||||
accountName = "acc@example.com",
|
||||
accountType = if (local) "LOCAL" else "com.google",
|
||||
color = 0,
|
||||
isVisibleInSystem = visible,
|
||||
canModifyContents = writable,
|
||||
isLocal = local,
|
||||
isManaged = managed,
|
||||
syncsEvents = syncs,
|
||||
)
|
||||
|
||||
private fun failure(state: BackupUiState) = (state as BackupUiState.Failure).reason
|
||||
|
||||
private fun ready(state: BackupUiState) = state as BackupUiState.Ready
|
||||
|
||||
@Test
|
||||
fun `no calendars at all reports the empty device`() {
|
||||
// This is #304's device. The screenshots on the issue show Calendula's
|
||||
// own calendar list with no local calendar and an empty "synced
|
||||
// calendars" section — the app saw nothing at all, so both halves of
|
||||
// Backup & restore had nothing to draw and the screen came up blank.
|
||||
// The reporter's "it's synced, I can see it in settings" meant Android's
|
||||
// settings, not this app's.
|
||||
assertThat(failure(backupUiState(emptyList())))
|
||||
.isEqualTo(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a synced writable calendar can receive a restore`() {
|
||||
val state = ready(backupUiState(listOf(cal(1L))))
|
||||
assertThat(state.canImport).isTrue()
|
||||
assertThat(state.exportable).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an account that has stopped syncing is no import target`() {
|
||||
// A calendar the provider no longer keeps events for: nothing to export
|
||||
// and nowhere to import to, which used to render a blank screen. Not
|
||||
// #304's device — that one had no calendars whatsoever — but the same
|
||||
// dead end, and reachable on its own.
|
||||
assertThat(failure(backupUiState(listOf(cal(1L, syncs = false)))))
|
||||
.isEqualTo(FailureReason.NoImportTarget)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read-only subscription is no import target either`() {
|
||||
assertThat(failure(backupUiState(listOf(cal(1L, writable = false)))))
|
||||
.isEqualTo(FailureReason.NoImportTarget)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `everything switched off is reported as hidden, not as a missing target`() {
|
||||
// The remedy differs: a visibility switch fixes this one.
|
||||
assertThat(failure(backupUiState(listOf(cal(1L, visible = false)))))
|
||||
.isEqualTo(FailureReason.AllCalendarsHidden)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a hidden local calendar still exports`() {
|
||||
// Export reads the provider directly, so system visibility is irrelevant
|
||||
// to it — calling this a failure would hide a working action.
|
||||
val state = ready(backupUiState(listOf(cal(1L, local = true, visible = false))))
|
||||
assertThat(state.exportable).hasSize(1)
|
||||
assertThat(state.canImport).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a managed special-dates mirror is neither exportable nor a target`() {
|
||||
assertThat(failure(backupUiState(listOf(cal(1L, local = true, managed = true)))))
|
||||
.isEqualTo(FailureReason.NoImportTarget)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a local calendar alongside a synced one offers both halves`() {
|
||||
val state = ready(backupUiState(listOf(cal(1L, local = true), cal(2L))))
|
||||
assertThat(state.exportable.map { it.id }).containsExactly(1L)
|
||||
assertThat(state.canImport).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class BlockTextMetricsTest {
|
||||
|
||||
/** One trimmed labelMedium line at font scale 1: a 12sp glyph, leading off. */
|
||||
private val titleLine = 14.dp
|
||||
|
||||
/** A block with room to spare, for the line arithmetic. */
|
||||
private fun metrics(height: Dp = 100.dp) = BlockTextMetrics(
|
||||
inset = blockTextInset(height, titleLine),
|
||||
available = height - blockTextInset(height, titleLine) * 2,
|
||||
titleLine = titleLine,
|
||||
titleLeading = 16.dp,
|
||||
timeLine = 13.dp,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a block with room keeps the full inset`() {
|
||||
assertThat(blockTextInset(height = 60.dp, titleLine = titleLine))
|
||||
.isEqualTo(BLOCK_TEXT_INSET)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the inset tapers instead of snapping as the block shrinks`() {
|
||||
// Half the inset left over is half the inset kept, so a pinch closes the
|
||||
// gap frame by frame rather than dropping it in one.
|
||||
assertThat(blockTextInset(height = titleLine + 2.dp, titleLine = titleLine))
|
||||
.isEqualTo(1.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a block exactly one title tall spends nothing on padding`() {
|
||||
assertThat(blockTextInset(height = titleLine, titleLine = titleLine)).isEqualTo(0.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a block shorter than a line never insets negatively`() {
|
||||
assertThat(blockTextInset(height = 4.dp, titleLine = titleLine)).isEqualTo(0.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the title survives a block that used to be too short for it`() {
|
||||
// 18dp is under the old floor — a title line plus 2dp of inset at each
|
||||
// edge — and over the new one, which is the line on its own (#289).
|
||||
val m = metrics(height = 18.dp)
|
||||
assertThat(m.fitsTitle).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a block under one line still drops the title`() {
|
||||
assertThat(metrics(height = 10.dp).fitsTitle).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every title line after the first costs a whole line box`() {
|
||||
// The trim reaches the outer edges only, so the leading between two
|
||||
// lines is still there to pay for.
|
||||
val m = metrics()
|
||||
assertThat(m.titleHeight(1)).isEqualTo(14.dp)
|
||||
assertThat(m.titleHeight(2)).isEqualTo(30.dp)
|
||||
assertThat(m.titleHeight(3)).isEqualTo(46.dp)
|
||||
assertThat(m.titleHeight(0)).isEqualTo(0.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the budget is what the block can actually draw, not what divides into it`() {
|
||||
val m = metrics()
|
||||
// Two lines cost 30dp: 29 buys one, 30 buys the second.
|
||||
assertThat(m.titleBudget(29.dp)).isEqualTo(1)
|
||||
assertThat(m.titleBudget(30.dp)).isEqualTo(2)
|
||||
assertThat(m.titleBudget(13.dp)).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a budget line is always one the block can pay for`() {
|
||||
val m = metrics()
|
||||
(0..80).forEach { dp ->
|
||||
val within = dp.dp
|
||||
val budget = m.titleBudget(within)
|
||||
assertThat(m.titleHeight(budget)).isAtMost(within)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.math.abs
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PageStateCacheTest {
|
||||
|
||||
@Test
|
||||
fun `the same key shares one state`() = runTest {
|
||||
val cache = PageStateCache<Int, Int>(backgroundScope, 0, { a, b -> abs(a - b) <= 1 }) { flow { emit(it) } }
|
||||
assertThat(cache.get(5)).isSameInstanceAs(cache.get(5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a dropped entry stops loading`() = runTest(UnconfinedTestDispatcher()) {
|
||||
var running = 0
|
||||
val cache = PageStateCache<Int, Int>(backgroundScope, 0, { a, b -> abs(a - b) <= 1 }) { key ->
|
||||
flow {
|
||||
running++
|
||||
try {
|
||||
emit(key)
|
||||
awaitCancellation()
|
||||
} finally {
|
||||
running--
|
||||
}
|
||||
}
|
||||
}
|
||||
backgroundScope.launch { cache.get(0).collect {} }
|
||||
assertThat(running).isEqualTo(1)
|
||||
|
||||
// Far enough away that 0 is let go, even though it is still collected.
|
||||
cache.get(10)
|
||||
assertThat(running).isEqualTo(0)
|
||||
assertThat(cache.size).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nearby entries are kept`() = runTest {
|
||||
val cache = PageStateCache<Int, Int>(backgroundScope, 0, { a, b -> abs(a - b) <= 2 }) { flow { emit(it) } }
|
||||
cache.get(4)
|
||||
cache.get(5)
|
||||
cache.get(6)
|
||||
assertThat(cache.size).isEqualTo(3)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -75,17 +76,16 @@ class TimelineZoomTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pinch held against a fractional bound stays put`() {
|
||||
// A bound that is not a whole pixel is a height the pinch can be pushed
|
||||
// against but never land on, so every frame of a held gesture would look
|
||||
// like a scale change and hand the focal anchor a scroll correction.
|
||||
fun `a pinch held against either bound stays put`() {
|
||||
val fillPx = 62.083f
|
||||
val maxPx = 616.5f
|
||||
|
||||
val floor = pinchedHourHeightPx(target = 1f, fillPx, maxPx)
|
||||
val ceiling = pinchedHourHeightPx(target = 9_000f, fillPx, maxPx)
|
||||
|
||||
assertThat(floor).isEqualTo(63f)
|
||||
// The ceiling is pulled onto the pixel grid so it stays landable; the
|
||||
// fill floor is landable as it is, being the clamp result itself.
|
||||
assertThat(floor).isEqualTo(fillPx)
|
||||
assertThat(ceiling).isEqualTo(616f)
|
||||
// Landing there and being pushed further must not move them again.
|
||||
assertThat(pinchedHourHeightPx(floor * 0.9f, fillPx, maxPx)).isEqualTo(floor)
|
||||
@@ -100,6 +100,35 @@ class TimelineZoomTest {
|
||||
assertThat(floor * 24).isAtLeast(viewport)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pinching all the way out leaves nothing to scroll`() {
|
||||
// #290: rounding the fill floor up to a whole pixel made the day one
|
||||
// pixel per hour taller than the viewport it was supposed to fill, so a
|
||||
// fully zoomed-out timeline still scrolled a hair and bounced off
|
||||
// Android's overscroll stretch -- while FitDay, at the same zoom, sat
|
||||
// still. 1490 is deliberately not divisible by 24.
|
||||
val viewport = 1490f
|
||||
val floor = pinchedHourHeightPx(target = 1f, fillPx = viewport / 24f, maxPx = 616f)
|
||||
assertThat(floor * 24).isWithin(0.01f).of(viewport)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the pinch floor is the height FitDay resolves to`() {
|
||||
// The inconsistency the issue is about: the two ways to reach "the whole
|
||||
// day on one screen" have to arrive at the same height.
|
||||
val density = Density(2.5f)
|
||||
val viewport = 596.dp
|
||||
with(density) {
|
||||
val fitDay = TimelineScale.FitDay.hourHeight(viewport).toPx()
|
||||
val pinched = pinchedHourHeightPx(
|
||||
target = 1f,
|
||||
fillPx = fillHourHeight(viewport).toPx(),
|
||||
maxPx = MAX_PINCH_HOUR_HEIGHT.toPx(),
|
||||
)
|
||||
assertThat(pinched).isWithin(0.01f).of(fitDay)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a settled pinch is what gets persisted`() {
|
||||
var persisted: TimelineScale? = null
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.LocalDate
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class DayPagingTest {
|
||||
|
||||
@Test
|
||||
fun `a page and its day round-trip`() {
|
||||
listOf(LocalDate(2026, 9, 27), LocalDate(1990, 5, 16), LocalDate(2100, 2, 28)).forEach { day ->
|
||||
assertThat(dayForPage(dayPageFor(day))).isEqualTo(day)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adjacent pages are adjacent days across a month end`() {
|
||||
val page = dayPageFor(LocalDate(2026, 2, 28))
|
||||
assertThat(dayForPage(page + 1)).isEqualTo(LocalDate(2026, 3, 1))
|
||||
assertThat(dayForPage(page - 1)).isEqualTo(LocalDate(2026, 2, 27))
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,13 @@ class ChipAtCellYTest {
|
||||
)
|
||||
|
||||
private fun MonthWeek.chipAt(col: Int, cellY: Float) =
|
||||
chipAtCellY(col = col, cellY = cellY, bandTopInCell = bandTop, rowHeightPx = laneHeight)
|
||||
chipAtCellY(
|
||||
col = col,
|
||||
cellY = cellY,
|
||||
bandTopInCell = bandTop,
|
||||
rowHeightPx = laneHeight,
|
||||
laneCap = SPLIT_DOT_LANES,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a tap on a lane resolves to the chip seated there`() {
|
||||
@@ -98,13 +104,13 @@ class ChipAtCellYTest {
|
||||
|
||||
@Test
|
||||
fun `a tap on the overflow row opens the day rather than a hidden event`() {
|
||||
val events = (1..MAX_EVENT_ROWS + 2).map {
|
||||
val events = (1..SPLIT_DOT_LANES + 2).map {
|
||||
timed(LocalDate(2026, 7, 7), hour = it, id = it.toLong())
|
||||
}
|
||||
val week = rowOfJuly6(events)
|
||||
|
||||
// The dots sit one lane below the last one the row draws.
|
||||
val overflowY = bandTop + laneHeight * MAX_EVENT_ROWS + 2f
|
||||
val overflowY = bandTop + laneHeight * SPLIT_DOT_LANES + 2f
|
||||
assertThat(week.chipAt(col = 1, cellY = overflowY)).isNull()
|
||||
}
|
||||
|
||||
@@ -113,10 +119,22 @@ class ChipAtCellYTest {
|
||||
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)))
|
||||
|
||||
assertThat(
|
||||
week.chipAtCellY(col = 1, cellY = 45f, bandTopInCell = null, rowHeightPx = laneHeight),
|
||||
week.chipAtCellY(
|
||||
col = 1,
|
||||
cellY = 45f,
|
||||
bandTopInCell = null,
|
||||
rowHeightPx = laneHeight,
|
||||
laneCap = SPLIT_DOT_LANES,
|
||||
),
|
||||
).isNull()
|
||||
assertThat(
|
||||
week.chipAtCellY(col = 1, cellY = 45f, bandTopInCell = bandTop, rowHeightPx = 0f),
|
||||
week.chipAtCellY(
|
||||
col = 1,
|
||||
cellY = 45f,
|
||||
bandTopInCell = bandTop,
|
||||
rowHeightPx = 0f,
|
||||
laneCap = SPLIT_DOT_LANES,
|
||||
),
|
||||
).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.Month
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.toInstant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* How many chip lanes a week row seats at a given height (#190) — the cap that
|
||||
* used to be a flat three whatever the device had.
|
||||
*/
|
||||
class MonthLaneCapTest {
|
||||
|
||||
private val zone = TimeZone.UTC
|
||||
private val jul26 = YearMonth(2026, Month.JULY)
|
||||
private val monday = LocalDate(2026, 7, 6)
|
||||
|
||||
/** Cell chrome above the band: 6 + 22 + 4. */
|
||||
private val header = 32.dp
|
||||
|
||||
private fun timed(day: LocalDate, hour: Int, id: Long) = EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = "T$id",
|
||||
start = day.atTime(hour, 0).toInstant(zone),
|
||||
end = day.atTime(hour + 1, 0).toInstant(zone),
|
||||
isAllDay = false,
|
||||
color = 0xFFF44336.toInt(),
|
||||
location = null,
|
||||
)
|
||||
|
||||
/** Jul 6-12, wholly inside July 2026. */
|
||||
private fun week(eventsOnMonday: Int) = layoutMonthWeeks(
|
||||
jul26,
|
||||
DayOfWeek.MONDAY,
|
||||
(1..eventsOnMonday).map { timed(monday, hour = it, id = it.toLong()) },
|
||||
zone,
|
||||
)[1]
|
||||
|
||||
private fun rowFor(bandHeight: Int) = header + bandHeight.dp
|
||||
|
||||
/** A labelSmall line at font scale 1 — what the overflow row measures to. */
|
||||
private val overflowRow = 16.dp
|
||||
|
||||
private fun MonthWeek.capAt(rowHeight: androidx.compose.ui.unit.Dp) =
|
||||
laneCapFor(rowHeight, overflowRow)
|
||||
|
||||
@Test
|
||||
fun `a week that fits spends the overflow row on another lane`() {
|
||||
// 80dp of band is four 20dp lanes. Nothing overflows at four, so no
|
||||
// room is set aside for a marker that would never be drawn.
|
||||
assertThat(week(eventsOnMonday = 4).capAt(rowFor(80))).isEqualTo(4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a week that overflows pays for the dots out of its own lanes`() {
|
||||
// The same 80dp, but a fifth event means the dots have to be drawn, and
|
||||
// their 14dp comes off the band before it is divided.
|
||||
assertThat(week(eventsOnMonday = 5).capAt(rowFor(80))).isEqualTo(3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a taller row seats more, with no ceiling`() {
|
||||
assertThat(week(eventsOnMonday = 20).capAt(rowFor(120))).isEqualTo(5)
|
||||
assertThat(week(eventsOnMonday = 20).capAt(rowFor(220))).isEqualTo(10)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cramped row seats fewer rather than drawing past its band`() {
|
||||
// 58dp was three clipped lanes and dots nobody could see. It is two
|
||||
// lanes and a visible marker.
|
||||
assertThat(week(eventsOnMonday = 6).capAt(rowFor(58))).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a row with no height to give still seats one lane`() {
|
||||
assertThat(week(eventsOnMonday = 6).capAt(rowFor(0))).isEqualTo(1)
|
||||
assertThat(week(eventsOnMonday = 6).capAt(10.dp)).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty week never charges itself for dots`() {
|
||||
assertThat(week(eventsOnMonday = 0).capAt(rowFor(80))).isEqualTo(4)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ class OnboardingPlanTest {
|
||||
viewDone: Boolean = false,
|
||||
monthStyleDone: Boolean = false,
|
||||
backupApplies: Boolean? = null,
|
||||
hiddenDone: Boolean = false,
|
||||
hiddenApplies: Boolean? = false,
|
||||
calendarsDone: Boolean = false,
|
||||
calendarsApplies: Boolean? = false,
|
||||
visibilityArmed: Boolean = false,
|
||||
visibilityDone: Boolean = false,
|
||||
doneShown: Boolean = false,
|
||||
@@ -29,6 +33,10 @@ class OnboardingPlanTest {
|
||||
viewDone = viewDone,
|
||||
monthStyleDone = monthStyleDone,
|
||||
backupApplies = backupApplies,
|
||||
hiddenDone = hiddenDone,
|
||||
hiddenApplies = hiddenApplies,
|
||||
calendarsDone = calendarsDone,
|
||||
calendarsApplies = calendarsApplies,
|
||||
visibilityArmed = visibilityArmed,
|
||||
visibilityDone = visibilityDone,
|
||||
doneShown = doneShown,
|
||||
@@ -243,4 +251,144 @@ class OnboardingPlanTest {
|
||||
)
|
||||
assertThat(read.current).isNull()
|
||||
}
|
||||
|
||||
// --- the calendar step (#287) -----------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a device with no calendar is offered one, right after the grant`() {
|
||||
val fresh = plan(hasPermission = true, wizardArmed = true, calendarsApplies = null)
|
||||
assertThat(fresh.steps).contains(OnboardingStep.Calendars)
|
||||
assertThat(fresh.current).isEqualTo(OnboardingStep.Calendars)
|
||||
// Before reminders: there is no point asking about notifications for
|
||||
// events that have nowhere to live.
|
||||
assertThat(fresh.steps.indexOf(OnboardingStep.Calendars))
|
||||
.isLessThan(fresh.steps.indexOf(OnboardingStep.Reminders))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a device that already has a calendar never sees the step`() {
|
||||
val fresh = plan(hasPermission = true, wizardArmed = true, calendarsApplies = false)
|
||||
assertThat(fresh.steps).doesNotContain(OnboardingStep.Calendars)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the step is assumed to apply until a calendar list proves otherwise`() {
|
||||
// Same contract the backup step keeps: an unknown answer keeps the step,
|
||||
// so the plan can only ever get shorter and the counter never grows.
|
||||
val unknown = plan(hasPermission = true, wizardArmed = true, calendarsApplies = null)
|
||||
val known = plan(hasPermission = true, wizardArmed = true, calendarsApplies = false)
|
||||
assertThat(unknown.steps).contains(OnboardingStep.Calendars)
|
||||
assertThat(known.total).isLessThan(unknown.total)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `answering the step moves on without renumbering the flow`() {
|
||||
val before = plan(hasPermission = true, wizardArmed = true, calendarsApplies = null)
|
||||
val after = before.let {
|
||||
plan(
|
||||
hasPermission = true,
|
||||
wizardArmed = true,
|
||||
calendarsApplies = null,
|
||||
calendarsDone = true,
|
||||
)
|
||||
}
|
||||
assertThat(after.current).isEqualTo(OnboardingStep.Reminders)
|
||||
assertThat(after.steps).isEqualTo(before.steps)
|
||||
assertThat(after.total).isEqualTo(before.total)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an existing install is never given the calendar step`() {
|
||||
// Not gated on `fresh` would re-onboard everyone whose read momentarily
|
||||
// came back empty.
|
||||
val existing = plan(
|
||||
hasPermission = true,
|
||||
remindersDone = true,
|
||||
calendarsApplies = null,
|
||||
)
|
||||
assertThat(existing.steps).doesNotContain(OnboardingStep.Calendars)
|
||||
}
|
||||
|
||||
// --- every calendar switched off (#287) -------------------------------
|
||||
|
||||
@Test
|
||||
fun `a fresh install whose calendars are all off is shown them first`() {
|
||||
// Nothing visible leaves the calendar step unresolved too, which is what
|
||||
// puts the two in the same flow.
|
||||
val hidden = plan(
|
||||
hasPermission = true,
|
||||
wizardArmed = true,
|
||||
hiddenApplies = true,
|
||||
calendarsApplies = null,
|
||||
)
|
||||
assertThat(hidden.current).isEqualTo(OnboardingStep.HiddenCalendars)
|
||||
// Switching one back on is the lighter remedy, so it comes before the
|
||||
// offer to make a calendar.
|
||||
assertThat(hidden.steps.indexOf(OnboardingStep.HiddenCalendars))
|
||||
.isLessThan(hidden.steps.indexOf(OnboardingStep.Calendars))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `declining them leaves the offer to make a calendar standing`() {
|
||||
// The whole point of the order: someone who will not switch their
|
||||
// calendars on still gets the offer a device with none at all gets.
|
||||
val declined = plan(
|
||||
hasPermission = true,
|
||||
wizardArmed = true,
|
||||
hiddenApplies = true,
|
||||
hiddenDone = true,
|
||||
calendarsApplies = null,
|
||||
)
|
||||
assertThat(declined.current).isEqualTo(OnboardingStep.Calendars)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching one on drops the offer to make a calendar`() {
|
||||
// Something is showing now, so there is somewhere to put an event; the
|
||||
// step the user is standing on is held in place by the view model.
|
||||
val fixed = plan(
|
||||
hasPermission = true,
|
||||
wizardArmed = true,
|
||||
hiddenApplies = null,
|
||||
calendarsApplies = false,
|
||||
)
|
||||
assertThat(fixed.steps).contains(OnboardingStep.HiddenCalendars)
|
||||
assertThat(fixed.steps).doesNotContain(OnboardingStep.Calendars)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fresh install with something visible never sees the step`() {
|
||||
val visible = plan(hasPermission = true, wizardArmed = true, hiddenApplies = false)
|
||||
assertThat(visible.steps).doesNotContain(OnboardingStep.HiddenCalendars)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the step is assumed until the calendar list proves something is visible`() {
|
||||
// The unreadable answer keeps the step, like the ones around it: the flow
|
||||
// shrinks when the list arrives, and the counter never grows.
|
||||
val unknown = plan(hasPermission = true, wizardArmed = true, hiddenApplies = null)
|
||||
val known = plan(hasPermission = true, wizardArmed = true, hiddenApplies = false)
|
||||
assertThat(unknown.steps).contains(OnboardingStep.HiddenCalendars)
|
||||
assertThat(known.total).isLessThan(unknown.total)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an existing install that switched its own calendars off is left alone`() {
|
||||
// Hiding every calendar from Settings is a deliberate act with an empty
|
||||
// state of its own (#239); it must not reopen the wizard.
|
||||
val existing = plan(hasPermission = true, remindersDone = true, hiddenApplies = true)
|
||||
assertThat(existing.steps).isEmpty()
|
||||
assertThat(existing.current).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the upgrade notice stays a notice at the end of the flow`() {
|
||||
val armed = plan(
|
||||
hasPermission = true,
|
||||
remindersDone = true,
|
||||
visibilityArmed = true,
|
||||
hiddenApplies = true,
|
||||
)
|
||||
assertThat(armed.steps).containsExactly(OnboardingStep.Visibility)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.theme
|
||||
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@@ -48,6 +49,19 @@ class FontsTest {
|
||||
assertThat(typography.labelSmall.fontFamily).isEqualTo(plain)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `picking a font keeps the tightened label tracking`() {
|
||||
// The font picker only swaps the family; the tracking the grids are laid
|
||||
// out against has to survive it (#190).
|
||||
val typography = calendulaTypography(
|
||||
brand = BundledFont.Lora.family,
|
||||
plain = BundledFont.AtkinsonHyperlegible.family,
|
||||
)
|
||||
|
||||
assertThat(typography.labelSmall.letterSpacing).isEqualTo(0.1.sp)
|
||||
assertThat(typography.labelMedium.letterSpacing).isEqualTo(0.1.sp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a null role keeps that role's default family while the other is applied`() {
|
||||
val plain = BundledFont.Lora.family
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.plus
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class WeekPagingTest {
|
||||
|
||||
// 2026-09-27 is a Sunday.
|
||||
private val sunday = LocalDate(2026, 9, 27)
|
||||
|
||||
@Test
|
||||
fun `a page and its week round-trip for every first day`() {
|
||||
DayOfWeek.entries.forEach { firstDay ->
|
||||
val page = weekPageFor(sunday, firstDay)
|
||||
val start = weekStartForPage(page, firstDay)
|
||||
assertThat(start).isEqualTo(sunday.startOfWeek(firstDay))
|
||||
assertThat(weekPageFor(start, firstDay)).isEqualTo(page)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every day of a week maps to the same page`() {
|
||||
val start = sunday.startOfWeek(DayOfWeek.MONDAY)
|
||||
val pages = (0..6).map { weekPageFor(start.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) }
|
||||
assertThat(pages.toSet()).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adjacent pages are adjacent weeks`() {
|
||||
val page = weekPageFor(sunday, DayOfWeek.SUNDAY)
|
||||
assertThat(weekStartForPage(page + 1, DayOfWeek.SUNDAY))
|
||||
.isEqualTo(LocalDate(2026, 10, 4))
|
||||
assertThat(weekStartForPage(page - 1, DayOfWeek.SUNDAY))
|
||||
.isEqualTo(LocalDate(2026, 9, 20))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dates before the epoch page resolve too`() {
|
||||
val old = LocalDate(1990, 5, 16)
|
||||
val page = weekPageFor(old, DayOfWeek.MONDAY)
|
||||
assertThat(weekStartForPage(page, DayOfWeek.MONDAY)).isEqualTo(LocalDate(1990, 5, 14))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
BEGIN:VCALENDAR
|
||||
PRODID:-//Google Inc//Google Calendar 70.9054//EN
|
||||
VERSION:2.0
|
||||
CALSCALE:GREGORIAN
|
||||
METHOD:PUBLISH
|
||||
X-WR-CALNAME:jane@example.com
|
||||
X-WR-TIMEZONE:Europe/Berlin
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Europe/Berlin
|
||||
X-LIC-LOCATION:Europe/Berlin
|
||||
BEGIN:DAYLIGHT
|
||||
TZOFFSETFROM:+0100
|
||||
TZOFFSETTO:+0200
|
||||
TZNAME:CEST
|
||||
DTSTART:19700329T020000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
|
||||
END:DAYLIGHT
|
||||
BEGIN:STANDARD
|
||||
TZOFFSETFROM:+0200
|
||||
TZOFFSETTO:+0100
|
||||
TZNAME:CET
|
||||
DTSTART:19701025T030000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
|
||||
END:STANDARD
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
DTSTART;TZID=Europe/Berlin:20260915T100000
|
||||
DTEND;TZID=Europe/Berlin:20260915T113000
|
||||
RRULE:FREQ=WEEKLY;BYDAY=TU
|
||||
EXDATE;TZID=Europe/Berlin:20260929T100000
|
||||
DTSTAMP:20260912T183000Z
|
||||
UID:0abcdef1234567890abcdef12345@google.com
|
||||
CREATED:20260901T120000Z
|
||||
DESCRIPTION:Weekly sync with the team.\nAgenda lives in the shared doc\, see
|
||||
the link below.
|
||||
LAST-MODIFIED:20260902T081500Z
|
||||
LOCATION:Meeting room 2\, 3rd floor
|
||||
SEQUENCE:0
|
||||
STATUS:CONFIRMED
|
||||
SUMMARY:Team standup
|
||||
TRANSP:OPAQUE
|
||||
ORGANIZER;CN=Jane Doe:mailto:jane@example.com
|
||||
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=John:m
|
||||
ailto:john@example.com
|
||||
BEGIN:VALARM
|
||||
ACTION:DISPLAY
|
||||
DESCRIPTION:This is an event reminder
|
||||
TRIGGER:-PT30M
|
||||
END:VALARM
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART;TZID=Europe/Berlin:20260929T140000
|
||||
DTEND;TZID=Europe/Berlin:20260929T153000
|
||||
DTSTAMP:20260912T183000Z
|
||||
UID:0abcdef1234567890abcdef12345@google.com
|
||||
RECURRENCE-ID;TZID=Europe/Berlin:20260929T100000
|
||||
CREATED:20260901T120000Z
|
||||
LAST-MODIFIED:20260910T091500Z
|
||||
SEQUENCE:1
|
||||
STATUS:CONFIRMED
|
||||
SUMMARY:Team standup (moved)
|
||||
TRANSP:OPAQUE
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART;VALUE=DATE:20261024
|
||||
DTEND;VALUE=DATE:20261025
|
||||
DTSTAMP:20260912T183000Z
|
||||
UID:1bcdef01234567890abcdef23456@google.com
|
||||
CREATED:20260820T101500Z
|
||||
LAST-MODIFIED:20260820T101500Z
|
||||
SEQUENCE:0
|
||||
STATUS:CONFIRMED
|
||||
SUMMARY:Day off
|
||||
TRANSP:TRANSPARENT
|
||||
BEGIN:VALARM
|
||||
ACTION:DISPLAY
|
||||
DESCRIPTION:This is an event reminder
|
||||
TRIGGER:-P1D
|
||||
END:VALARM
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART;VALUE=DATE:20261224
|
||||
DTEND;VALUE=DATE:20261227
|
||||
DTSTAMP:20260912T183000Z
|
||||
UID:2cdef012345678901abcdef34567@google.com
|
||||
SEQUENCE:0
|
||||
STATUS:CONFIRMED
|
||||
SUMMARY:Christmas break
|
||||
TRANSP:TRANSPARENT
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -113,6 +113,19 @@ notice (#75) is a step in front of it rather than a dialog over the app — it
|
||||
is the only optional step an *already onboarded* install can be given, which
|
||||
is why it alone is not gated on the wizard being armed.
|
||||
|
||||
"Every calendar is switched off" is a *state*, not that announcement, so it
|
||||
is a step of its own at the front of the flow (#287) rather than another face
|
||||
of the notice: the notice is settled once and for good, while the state is
|
||||
derived from the live calendar list on every launch. It lists the calendars
|
||||
with their own switches — switching one on un-hides it in every other calendar
|
||||
app too, which is not a choice to make on the app's word — and it sits *in
|
||||
front of* the calendar step, so declining it falls through to the offer to
|
||||
make a calendar that a device with none at all gets. That is also why
|
||||
`calendarsApplies` resolves on "something is visible" rather than "the list is
|
||||
non-empty". Being derived, it is gated on the wizard being armed: an install
|
||||
that switched its own calendars off from Settings meant it, and has the in-app
|
||||
empty state (#239) instead.
|
||||
|
||||
## Recurring writes
|
||||
|
||||
The provider's invariants drive the design (learned the hard way, verified
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Privacy Policy — Calendula
|
||||
description: What Calendula does with your data. It collects nothing, sends nothing, and has no internet permission at all.
|
||||
updated: 2026-07-28
|
||||
updated: 2026-09-15
|
||||
---
|
||||
|
||||
<!--
|
||||
@@ -33,7 +33,7 @@ Calendula collects nothing, sends nothing, and has no user accounts. It has **no
|
||||
IT-Dienstleister | Jean-Luc Makiola
|
||||
Mahlerstraße 10
|
||||
14772 Brandenburg an der Havel
|
||||
Email: [business@jeanlucmakiola.de](mailto:business@jeanlucmakiola.de)
|
||||
Email: [support@jeanlucmakiola.de](mailto:support@jeanlucmakiola.de)
|
||||
|
||||
## 2. No data collection
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
إصلاحات
|
||||
• لم يعد النطاق المخصص في الجدول يُعطِّل التطبيق بالفرنسية. كان اختيار نطاق مخصص — أو مجرّد كتابة رقم في ذلك الحقل — يُغلق التطبيق على أي هاتف مضبوط على الفرنسية، ولم ينجُ سوى نطاق يوم واحد بالضبط. أما اللغات الأخرى فلم تتأثّر قطّ.
|
||||
@@ -0,0 +1,2 @@
|
||||
Behoben
|
||||
• Ein benutzerdefinierter Agenda-Zeitraum stürzt auf Französisch nicht mehr ab. Einen eigenen Zeitraum zu wählen – oder auch nur eine Zahl in das Feld zu tippen – beendete die App auf jedem Gerät mit französischer Sprache; nur genau ein Tag blieb verschont. Andere Sprachen waren nie betroffen.
|
||||
@@ -0,0 +1,2 @@
|
||||
Fixed
|
||||
• A custom Agenda range no longer crashes the app in French. Choosing a custom range — or just typing a number into that field — closed the app on any phone set to French, and only a range of exactly one day escaped it. Other languages were never affected.
|
||||
@@ -0,0 +1,2 @@
|
||||
Fixed
|
||||
• A custom Agenda range no longer crashes the app in French. Choosing a custom range — or just typing a number into that field — closed the app on any phone set to French, and only a range of exactly one day escaped it. Other languages were never affected.
|
||||
@@ -0,0 +1,3 @@
|
||||
Fixed
|
||||
• The app no longer reopens the event you last came in on. Opening an event from a reminder or a widget made it return to that event on every later launch, or show an error screen once the event was gone.
|
||||
• "Today" works in the Agenda again. It now scrolls the list back to today, and stays offered while you are scrolled away from it.
|
||||
@@ -0,0 +1,11 @@
|
||||
New
|
||||
• Setup now makes sure you have a calendar to write to — create one, add an account, or switch a hidden one back on.
|
||||
|
||||
Changed
|
||||
• The month grid gives its days more room: smaller week numbers, a measured gutter, tighter chips.
|
||||
• A month day shows as many events as it has height for, instead of a flat three.
|
||||
• Split view no longer pads a short month out to six rows.
|
||||
|
||||
Fixed
|
||||
• Short events show their title in week and day view.
|
||||
• Untitled events and unnamed calendars read in your language.
|
||||
@@ -0,0 +1,2 @@
|
||||
Correcciones
|
||||
• El intervalo personalizado de la Agenda ya no bloquea la aplicación en francés. Elegir un intervalo propio —o solo escribir un número en ese campo— cerraba la aplicación en cualquier teléfono en francés, y únicamente un intervalo de exactamente un día se salvaba. Los demás idiomas nunca se vieron afectados.
|
||||
@@ -0,0 +1,2 @@
|
||||
Corrections
|
||||
• La plage personnalisée de l’Agenda ne fait plus planter l’application en français. Choisir une plage personnalisée — ou simplement saisir un nombre dans ce champ — fermait l’application sur tout téléphone en français ; seule une plage d’exactement un jour y échappait.
|
||||
@@ -0,0 +1,2 @@
|
||||
Correzioni
|
||||
• L’intervallo personalizzato dell’Agenda non fa più chiudere l’app in francese. Scegliere un intervallo personalizzato — o anche solo digitare un numero in quel campo — chiudeva l’app su qualsiasi telefono impostato in francese; solo un intervallo di esattamente un giorno ne era immune. Le altre lingue non sono mai state interessate.
|
||||
@@ -0,0 +1,2 @@
|
||||
Poprawki
|
||||
• Niestandardowy zakres Agendy nie powoduje już awarii aplikacji w języku francuskim. Wybranie własnego zakresu – albo samo wpisanie liczby w to pole – zamykało aplikację na każdym telefonie ustawionym na francuski; bezpieczny był tylko zakres dokładnie jednego dnia. Innych języków to nigdy nie dotyczyło.
|
||||
@@ -0,0 +1,2 @@
|
||||
Correções
|
||||
• O intervalo personalizado da Agenda não fecha mais o aplicativo em francês. Escolher um intervalo personalizado — ou apenas digitar um número nesse campo — fechava o aplicativo em qualquer telefone configurado em francês; só um intervalo de exatamente um dia escapava. Os outros idiomas nunca foram afetados.
|
||||
@@ -0,0 +1,2 @@
|
||||
Correções
|
||||
• O intervalo personalizado da Agenda já não fecha a aplicação em francês. Escolher um intervalo personalizado — ou apenas escrever um número nesse campo — fechava a aplicação em qualquer telemóvel definido em francês; só um intervalo de exatamente um dia escapava. Os outros idiomas nunca foram afetados.
|
||||
@@ -0,0 +1,2 @@
|
||||
Исправлено
|
||||
• Свой диапазон в Расписании больше не приводит к сбою на французском языке. Выбор своего диапазона — или даже простой ввод числа в это поле — закрывал приложение на любом телефоне с французским языком; уцелел только диапазон ровно в один день. Другие языки это никогда не затрагивало.
|
||||
@@ -0,0 +1,2 @@
|
||||
修复
|
||||
• 法语下的日程自定义范围不再导致应用崩溃。选择自定义范围,甚至只是在该字段中输入数字,都会让应用在设置为法语的手机上关闭,只有恰好一天的范围不受影响。其他语言从未受到影响。
|
||||
@@ -4,7 +4,7 @@ Name: Calendula
|
||||
Summary: A modern Material 3 Expressive calendar for Android.
|
||||
|
||||
Categories:
|
||||
- Time
|
||||
- Calendar & Agenda
|
||||
|
||||
SourceCode: https://codeberg.org/jlmakiola/calendula
|
||||
IssueTracker: https://codeberg.org/jlmakiola/calendula/issues
|
||||
|
||||
@@ -8,7 +8,10 @@ and are produced via Weblate. This guard keeps incoming translation PRs honest:
|
||||
* a translation must not define keys absent from the base — those are stale
|
||||
keys left behind after a rename/removal upstream;
|
||||
* a translation must not translate strings marked ``translatable="false"`` in
|
||||
the base (URLs, IDs and the like).
|
||||
the base (URLs, IDs and the like);
|
||||
* every translated ``<plurals>`` must carry an ``other`` item, because that
|
||||
is the one quantity form Android cannot fall back for (see
|
||||
``plurals_missing_other``).
|
||||
|
||||
Missing keys are *allowed* and only reported as coverage: a missing string
|
||||
falls back to the English base at runtime, so partial translations are fine
|
||||
@@ -27,10 +30,13 @@ RES_DIR = Path("app/src/main/res")
|
||||
BASE = RES_DIR / "values" / "strings.xml"
|
||||
RESOURCE_TAGS = ("string", "plurals", "string-array")
|
||||
|
||||
# The quantity Android itself falls back to, and therefore the one a translated
|
||||
# <plurals> may never omit.
|
||||
PLURAL_FALLBACK = "other"
|
||||
|
||||
def entries(path: Path) -> dict[str, bool]:
|
||||
"""Map resource name -> is-translatable for every entry in ``path``."""
|
||||
root = ET.parse(path).getroot()
|
||||
|
||||
def entries(root: ET.Element) -> dict[str, bool]:
|
||||
"""Map resource name -> is-translatable for every entry under ``root``."""
|
||||
return {
|
||||
el.attrib["name"]: el.attrib.get("translatable", "true") != "false"
|
||||
for el in root
|
||||
@@ -38,12 +44,35 @@ def entries(path: Path) -> dict[str, bool]:
|
||||
}
|
||||
|
||||
|
||||
def plurals_missing_other(root: ET.Element) -> list[str]:
|
||||
"""Names of ``<plurals>`` under ``root`` that lack an ``other`` item.
|
||||
|
||||
Android resolves the CLDR quantity for a count and, if that specific form is
|
||||
absent, falls back to ``other`` — so a translation may legitimately skip
|
||||
forms its locale rarely uses (Arabic ``zero``, French ``many``). There is no
|
||||
fallback for ``other`` itself, and none to the base locale either: a
|
||||
translated ``<plurals>`` without it raises
|
||||
``Resources$NotFoundException`` as soon as a count selects the missing form.
|
||||
|
||||
That is Codeberg #297/#298 — ``values-fr`` translated ``agenda_range_days``
|
||||
with only ``one`` and ``many``, and since French ``many`` matches only
|
||||
millions, every custom agenda range from 2 days up crashed the app.
|
||||
"""
|
||||
return sorted(
|
||||
el.attrib["name"]
|
||||
for el in root
|
||||
if el.tag == "plurals"
|
||||
and "name" in el.attrib
|
||||
and not any(item.attrib.get("quantity") == PLURAL_FALLBACK for item in el.findall("item"))
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not BASE.exists():
|
||||
print(f"::error::base resource file {BASE} not found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
base = entries(BASE)
|
||||
base = entries(ET.parse(BASE).getroot())
|
||||
base_keys = set(base)
|
||||
nontranslatable = {name for name, ok in base.items() if not ok}
|
||||
translatable_total = len(base_keys - nontranslatable)
|
||||
@@ -57,16 +86,18 @@ def main() -> int:
|
||||
for path in files:
|
||||
locale = path.parent.name[len("values-"):]
|
||||
try:
|
||||
translated = entries(path)
|
||||
root = ET.parse(path).getroot()
|
||||
except ET.ParseError as exc:
|
||||
print(f"::error file={path}::{locale}: malformed XML: {exc}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
translated = entries(root)
|
||||
keys = set(translated)
|
||||
stale = sorted(keys - base_keys)
|
||||
translated_fixed = sorted(keys & nontranslatable)
|
||||
missing = base_keys - nontranslatable - keys
|
||||
no_other = plurals_missing_other(root)
|
||||
|
||||
for name in stale:
|
||||
print(f"::error file={path}::{locale}: stale key '{name}' is not in the base strings.xml")
|
||||
@@ -77,10 +108,18 @@ def main() -> int:
|
||||
"in the base and must not be translated"
|
||||
)
|
||||
errors += 1
|
||||
for name in no_other:
|
||||
print(
|
||||
f"::error file={path}::{locale}: plurals '{name}' has no "
|
||||
f"<item quantity=\"{PLURAL_FALLBACK}\"> — Android cannot fall back for it "
|
||||
"and will crash when a count selects it; translate that form or drop the "
|
||||
"whole plurals to inherit the base"
|
||||
)
|
||||
errors += 1
|
||||
|
||||
covered = translatable_total - len(missing)
|
||||
pct = covered * 100 // translatable_total if translatable_total else 100
|
||||
verdict = "OK" if not (stale or translated_fixed) else "FAIL"
|
||||
verdict = "OK" if not (stale or translated_fixed or no_other) else "FAIL"
|
||||
print(f"{locale:<10} {covered}/{translatable_total} keys ({pct}%) — {verdict}")
|
||||
|
||||
if errors:
|
||||
|
||||
Reference in New Issue
Block a user