Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b0ea93f63 |
@@ -5,73 +5,6 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [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
|
## [2.20.3] — 2026-09-12
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -1760,17 +1693,3 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#239]: https://codeberg.org/jlmakiola/calendula/issues/239
|
[#239]: https://codeberg.org/jlmakiola/calendula/issues/239
|
||||||
[#297]: https://codeberg.org/jlmakiola/calendula/issues/297
|
[#297]: https://codeberg.org/jlmakiola/calendula/issues/297
|
||||||
[#298]: https://codeberg.org/jlmakiola/calendula/issues/298
|
[#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
|
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 22100
|
versionCode = 22003
|
||||||
versionName = "2.21.0"
|
versionName = "2.20.3"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,26 +113,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
||||||
Configuration.UI_MODE_NIGHT_YES
|
Configuration.UI_MODE_NIGHT_YES
|
||||||
applyEdgeToEdge()
|
applyEdgeToEdge()
|
||||||
// Android hands the intent that started the task back to onCreate every
|
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||||
// 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()
|
requestedNav = intent.navRequestOrNull()
|
||||||
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
|
|
||||||
requestedImportUri = intent.importUriOrNull()
|
requestedImportUri = intent.importUriOrNull()
|
||||||
requestedInsert = intent.insertRequestOrNull()
|
requestedInsert = intent.insertRequestOrNull()
|
||||||
|
requestedEditKey = intent.editEventKeyOrNull()
|
||||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
setContent {
|
setContent {
|
||||||
// One activity-scoped SettingsViewModel drives both the theme here
|
// One activity-scoped SettingsViewModel drives both the theme here
|
||||||
@@ -257,16 +242,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
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`)
|
* 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
|
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
|
||||||
|
|||||||
+15
-28
@@ -1,35 +1,22 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The colours offered when creating or editing a calendar; ARGB ints for a raw
|
* Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` /
|
||||||
* `CALENDAR_COLOR` / `EVENT_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
|
||||||
* Hues rather than final fills: every surface that draws a calendar colour runs
|
* swatches offered in the colour picker.
|
||||||
* 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 {
|
object CalendarColorPalette {
|
||||||
val Mauve = 0xFF7A5C6B.toInt()
|
val Red = 0xFFD50000.toInt()
|
||||||
val Red = 0xFFD7484A.toInt()
|
val Orange = 0xFFE67C00.toInt()
|
||||||
val Orange = 0xFFE8743B.toInt()
|
val Amber = 0xFFF6BF26.toInt()
|
||||||
val Amber = 0xFFE0A32E.toInt()
|
val Green = 0xFF33B679.toInt()
|
||||||
val Olive = 0xFF7CA83E.toInt()
|
val DarkGreen = 0xFF0B8043.toInt()
|
||||||
val Green = 0xFF35A06A.toInt()
|
val Blue = 0xFF039BE5.toInt()
|
||||||
val Teal = 0xFF19938C.toInt()
|
val Indigo = 0xFF3F51B5.toInt()
|
||||||
val Cyan = 0xFF2A9BC4.toInt()
|
val Purple = 0xFF8E24AA.toInt()
|
||||||
val Blue = 0xFF3C74C8.toInt()
|
val Graphite = 0xFF616161.toInt()
|
||||||
val Indigo = 0xFF6A5CC0.toInt()
|
|
||||||
val Purple = 0xFF9455B8.toInt()
|
|
||||||
val Pink = 0xFFC94F8E.toInt()
|
|
||||||
|
|
||||||
/** The full palette, in swatch-grid order. */
|
/** The full palette, in swatch-row order. */
|
||||||
val all: List<Int> = listOf(
|
val all: List<Int> = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite)
|
||||||
Mauve, Red, Orange, Amber, Olive, Green,
|
|
||||||
Teal, Cyan, Blue, Indigo, Purple, Pink,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -376,7 +376,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
||||||
val name = displayName.trim()
|
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||||
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
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?) {
|
override fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) {
|
||||||
val name = displayName.trim()
|
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
||||||
put(CalendarContract.Calendars.NAME, name)
|
put(CalendarContract.Calendars.NAME, name)
|
||||||
@@ -464,7 +464,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
PackageManager.PERMISSION_GRANTED
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||||
val name = displayName.trim()
|
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||||
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
|||||||
val isLocal = accountType == CalendarContract.ACCOUNT_TYPE_LOCAL
|
val isLocal = accountType == CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||||
return CalendarSource(
|
return CalendarSource(
|
||||||
id = getLong(CalendarProjection.IDX_ID),
|
id = getLong(CalendarProjection.IDX_ID),
|
||||||
// Blank when the provider row has no name: the placeholder is a display
|
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
|
||||||
// string and belongs to the UI, where it can be localised (#329).
|
?: Fallbacks.UNNAMED_CALENDAR,
|
||||||
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME).orEmpty(),
|
|
||||||
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
|
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
|
||||||
accountType = accountType,
|
accountType = accountType,
|
||||||
color = getInt(CalendarProjection.IDX_COLOR),
|
color = getInt(CalendarProjection.IDX_COLOR),
|
||||||
|
|||||||
@@ -21,9 +21,8 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Left blank when the event has no title: the placeholder is a display
|
val rawTitle = getString(InstanceProjection.IDX_TITLE)
|
||||||
// string, so it belongs to the UI layer where it can be localised (#321).
|
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
||||||
val title = getString(InstanceProjection.IDX_TITLE).orEmpty()
|
|
||||||
|
|
||||||
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
|
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
|
||||||
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
|
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
|
||||||
|
|||||||
@@ -312,3 +312,8 @@ internal object ReminderProjection {
|
|||||||
const val IDX_MINUTES = 0
|
const val IDX_MINUTES = 0
|
||||||
const val IDX_METHOD = 1
|
const val IDX_METHOD = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal object Fallbacks {
|
||||||
|
const val UNNAMED_CALENDAR = "(Unbenannter Kalender)"
|
||||||
|
const val UNTITLED_EVENT = "(Ohne Titel)"
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
|||||||
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
||||||
}.coerceAtLeast(dtStart)
|
}.coerceAtLeast(dtStart)
|
||||||
|
|
||||||
val title = getString(SearchProjection.IDX_TITLE).orEmpty()
|
val rawTitle = getString(SearchProjection.IDX_TITLE)
|
||||||
|
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
||||||
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
|
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
|
||||||
getInt(SearchProjection.IDX_CALENDAR_COLOR)
|
getInt(SearchProjection.IDX_CALENDAR_COLOR)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -586,24 +586,6 @@ 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). */
|
/** Whether the wizard's backup step has been answered (or skipped). */
|
||||||
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
|
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
|
||||||
prefs[ONBOARDING_BACKUP_KEY] ?: false
|
prefs[ONBOARDING_BACKUP_KEY] ?: false
|
||||||
@@ -995,10 +977,6 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
|
internal val ONBOARDING_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_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
|
||||||
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
|
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
|
||||||
internal val ONBOARDING_MONTH_STYLE_KEY =
|
internal val ONBOARDING_MONTH_STYLE_KEY =
|
||||||
|
|||||||
@@ -253,13 +253,6 @@ enum class FailureReason {
|
|||||||
PermissionRevoked,
|
PermissionRevoked,
|
||||||
NoCalendarsConfigured,
|
NoCalendarsConfigured,
|
||||||
AllCalendarsHidden,
|
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,
|
ProviderUnavailable,
|
||||||
EventNotFound,
|
EventNotFound,
|
||||||
Unknown,
|
Unknown,
|
||||||
|
|||||||
@@ -166,19 +166,14 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
component != null -> {
|
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 end = indexOfEnd(lines, i + 1, component)
|
||||||
val body = indexOfNextComponent(lines, i + 1, end)
|
|
||||||
parseComponent(
|
parseComponent(
|
||||||
body = lines.subList(i + 1, body),
|
body = lines.subList(i + 1, end),
|
||||||
fileCalendarName = calendarName,
|
fileCalendarName = calendarName,
|
||||||
warnings = warnings,
|
warnings = warnings,
|
||||||
isTask = component == "VTODO",
|
isTask = component == "VTODO",
|
||||||
)?.let(events::add)
|
)?.let(events::add)
|
||||||
i = if (body < end) body else end + 1
|
i = end + 1
|
||||||
}
|
}
|
||||||
line.isBegin("VTIMEZONE") -> {
|
line.isBegin("VTIMEZONE") -> {
|
||||||
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
||||||
@@ -450,23 +445,6 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
/** 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 {
|
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
||||||
var i = from
|
var i = from
|
||||||
while (i < lines.size) {
|
while (i < lines.size) {
|
||||||
|
|||||||
@@ -197,21 +197,10 @@ fun CalendarHost(
|
|||||||
// is "restore a backup", not "add this one event". An externally opened .ics
|
// is "restore a backup", not "add this one event". An externally opened .ics
|
||||||
// keeps routing a single event straight into the prefilled create form.
|
// keeps routing a single event straight into the prefilled create form.
|
||||||
var importForceMany by remember { mutableStateOf(false) }
|
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) {
|
LaunchedEffect(requestedImportUri) {
|
||||||
if (requestedImportUri != null) {
|
if (requestedImportUri != null) {
|
||||||
importUri = requestedImportUri
|
importUri = requestedImportUri
|
||||||
importForceMany = false
|
importForceMany = false
|
||||||
importSession++
|
|
||||||
onImportConsumed()
|
onImportConsumed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -521,28 +510,11 @@ fun CalendarHost(
|
|||||||
importUri?.let { uri ->
|
importUri?.let { uri ->
|
||||||
ImportScreen(
|
ImportScreen(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
session = importSession,
|
|
||||||
forceMany = importForceMany,
|
forceMany = importForceMany,
|
||||||
onClose = {
|
onClose = { importUri = null },
|
||||||
importUri = null
|
onManageCalendars = { showCalendars = true },
|
||||||
// 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 ->
|
onOpenSingle = { form ->
|
||||||
importUri = null
|
importUri = null
|
||||||
calendarsAfterImport = false
|
|
||||||
backupAfterImport = false
|
|
||||||
importFormSource = ImportSource.File
|
importFormSource = ImportSource.File
|
||||||
importForm = form
|
importForm = form
|
||||||
},
|
},
|
||||||
@@ -578,33 +550,18 @@ fun CalendarHost(
|
|||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
) {
|
) {
|
||||||
// Settings can open this screen without the manager underneath, so
|
BackupScreen(
|
||||||
// its failure states' "Manage calendars" way out has to open the
|
onBack = { showBackup = false },
|
||||||
// manager rather than just pop — popping alone lands on Settings
|
// Restore runs the normal .ics import, and both this screen and
|
||||||
// (#304). Coming from the manager the flag is already set, so this
|
// the manager that can have opened it are declared above the
|
||||||
// is the same pop-back there.
|
// import overlays — so both have to step aside.
|
||||||
CompositionLocalProvider(
|
onImport = {
|
||||||
LocalManageCalendars provides {
|
importUri = it
|
||||||
showCalendars = true
|
importForceMany = true
|
||||||
showBackup = false
|
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,9 +13,7 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Coffee
|
import androidx.compose.material.icons.filled.Coffee
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
@@ -34,8 +32,6 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -110,27 +106,6 @@ fun AgendaScreen(
|
|||||||
}
|
}
|
||||||
val successState = state as? AgendaUiState.Success
|
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(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
drawerContent = {
|
drawerContent = {
|
||||||
@@ -164,15 +139,14 @@ fun AgendaScreen(
|
|||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
onToday = jumpToToday,
|
onToday = viewModel::goToToday,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
CalendarFabColumn(
|
CalendarFabColumn(
|
||||||
todayVisible = (!isOnToday || (scrolledAway && successState != null)) &&
|
todayVisible = !isOnToday && !todayInToolbar,
|
||||||
!todayInToolbar,
|
|
||||||
todayText = stringResource(R.string.agenda_today_action),
|
todayText = stringResource(R.string.agenda_today_action),
|
||||||
onToday = jumpToToday,
|
onToday = viewModel::goToToday,
|
||||||
onCreate = { onCreateEvent(anchor, null) },
|
onCreate = { onCreateEvent(anchor, null) },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -221,7 +195,6 @@ fun AgendaScreen(
|
|||||||
state = state,
|
state = state,
|
||||||
pastDisplay = pastDisplay,
|
pastDisplay = pastDisplay,
|
||||||
showToday = showToday,
|
showToday = showToday,
|
||||||
listState = listState,
|
|
||||||
onRetry = viewModel::goToToday,
|
onRetry = viewModel::goToToday,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
@@ -325,7 +298,6 @@ internal fun AgendaContent(
|
|||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
listState: LazyListState = rememberLazyListState(),
|
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
AgendaUiState.Loading -> Box(modifier)
|
AgendaUiState.Loading -> Box(modifier)
|
||||||
@@ -354,16 +326,12 @@ internal fun AgendaContent(
|
|||||||
enabled = showToday && state.anchor == state.today,
|
enabled = showToday && state.anchor == state.today,
|
||||||
)
|
)
|
||||||
if (days.isEmpty()) {
|
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)
|
AgendaEmpty(modifier)
|
||||||
} else {
|
} else {
|
||||||
AgendaList(
|
AgendaList(
|
||||||
days = days,
|
days = days,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
zone = state.zone,
|
zone = state.zone,
|
||||||
listState = listState,
|
|
||||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||||
now = now,
|
now = now,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
@@ -381,7 +349,6 @@ private fun AgendaList(
|
|||||||
days: List<AgendaDay>,
|
days: List<AgendaDay>,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
listState: LazyListState,
|
|
||||||
dimPast: Boolean,
|
dimPast: Boolean,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
@@ -390,7 +357,6 @@ private fun AgendaList(
|
|||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
state = listState,
|
|
||||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ import android.net.Uri
|
|||||||
import android.text.format.DateUtils
|
import android.text.format.DateUtils
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -20,7 +18,6 @@ import androidx.compose.material.icons.filled.Schedule
|
|||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
@@ -50,10 +47,9 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
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.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
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.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||||
@@ -84,13 +80,18 @@ fun BackupScreen(
|
|||||||
onImport: (Uri) -> Unit,
|
onImport: (Uri) -> Unit,
|
||||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.backupState.collectAsStateWithLifecycle()
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||||
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
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
|
// Exports everything eligible (null); the per-calendar selector owns its
|
||||||
// own launcher.
|
// own launcher.
|
||||||
val createBackup = rememberLauncherForActivityResult(
|
val createBackup = rememberLauncherForActivityResult(
|
||||||
@@ -131,42 +132,78 @@ fun BackupScreen(
|
|||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_backup),
|
title = stringResource(R.string.settings_section_backup),
|
||||||
onBack = onBack,
|
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) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
when (val s = state) {
|
HintText(stringResource(R.string.calendars_backup_hint))
|
||||||
BackupUiState.Loading -> BackupLoading()
|
|
||||||
is BackupUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onBack)
|
if (exportable.isNotEmpty()) {
|
||||||
is BackupUiState.Ready -> BackupContent(
|
GroupedRow(
|
||||||
exportable = s.exportable,
|
title = stringResource(R.string.calendars_backup_action),
|
||||||
canImport = s.canImport,
|
position = Position.Top,
|
||||||
autoBackup = autoBackup,
|
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||||
viewModel = viewModel,
|
onClick = {
|
||||||
onRestore = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
// A single exportable calendar skips the selector.
|
||||||
onPickFolder = { runCatching { pickFolder.launch(null) } },
|
if (exportable.size == 1) {
|
||||||
onExportAll = {
|
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||||
runCatching { createBackup.launch(defaultBackupName()) }
|
} else {
|
||||||
|
showExportPicker = true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onExportPick = { showExportPicker = true },
|
)
|
||||||
onEditInterval = { showInterval = true },
|
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) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gated on Ready rather than resetting the flag when the state leaves it:
|
if (showExportPicker) {
|
||||||
// flipping it here would be a write during composition.
|
ExportCalendarPicker(
|
||||||
(state as? BackupUiState.Ready)?.let { ready ->
|
calendars = exportable,
|
||||||
if (showExportPicker) {
|
onExport = viewModel::exportBackup,
|
||||||
ExportCalendarPicker(
|
onDismiss = { showExportPicker = false },
|
||||||
calendars = ready.exportable,
|
)
|
||||||
onExport = viewModel::exportBackup,
|
|
||||||
onDismiss = { showExportPicker = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (showInterval) {
|
if (showInterval) {
|
||||||
BackupIntervalDialog(
|
BackupIntervalDialog(
|
||||||
@@ -177,103 +214,6 @@ 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
|
* 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
|
* to all selected; the Export action opens the SAF save dialog and hands back
|
||||||
@@ -312,7 +252,7 @@ private fun ExportCalendarPicker(
|
|||||||
calendars.forEachIndexed { index, calendar ->
|
calendars.forEachIndexed { index, calendar ->
|
||||||
val isSelected = calendar.id in selected
|
val isSelected = calendar.id in selected
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
title = calendar.displayName,
|
||||||
summary = calendar.description,
|
summary = calendar.description,
|
||||||
position = positionOf(index, calendars.size),
|
position = positionOf(index, calendars.size),
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
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,7 +90,6 @@ import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||||
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||||
@@ -98,25 +97,11 @@ import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
|||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import 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". */
|
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||||
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||||
@@ -246,7 +231,7 @@ private fun CalendarsList(
|
|||||||
local.forEachIndexed { index, calendar ->
|
local.forEachIndexed { index, calendar ->
|
||||||
val disabled = !calendar.isVisibleInSystem
|
val disabled = !calendar.isVisibleInSystem
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
title = calendar.displayName,
|
||||||
summary = calendarRowSummary(calendar),
|
summary = calendarRowSummary(calendar),
|
||||||
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
@@ -254,7 +239,7 @@ private fun CalendarsList(
|
|||||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||||
trailing = {
|
trailing = {
|
||||||
EnableSwitch(
|
EnableSwitch(
|
||||||
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
calendarName = calendar.displayName,
|
||||||
enabled = !disabled,
|
enabled = !disabled,
|
||||||
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
||||||
)
|
)
|
||||||
@@ -331,7 +316,7 @@ private fun CalendarsList(
|
|||||||
ordered.forEachIndexed { index, calendar ->
|
ordered.forEachIndexed { index, calendar ->
|
||||||
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
title = calendar.displayName,
|
||||||
summary = calendarRowSummary(calendar),
|
summary = calendarRowSummary(calendar),
|
||||||
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
|
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
|
||||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
@@ -340,7 +325,7 @@ private fun CalendarsList(
|
|||||||
trailing = if (calendar.hasVisibilitySwitch) {
|
trailing = if (calendar.hasVisibilitySwitch) {
|
||||||
{
|
{
|
||||||
EnableSwitch(
|
EnableSwitch(
|
||||||
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
calendarName = calendar.displayName,
|
||||||
enabled = calendar.isVisibleInSystem,
|
enabled = calendar.isVisibleInSystem,
|
||||||
onToggle = { enabled ->
|
onToggle = { enabled ->
|
||||||
onSetVisible(calendar.id, enabled)
|
onSetVisible(calendar.id, enabled)
|
||||||
@@ -358,15 +343,9 @@ private fun CalendarsList(
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val SWATCHES_PER_ROW = 6
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
@Composable
|
||||||
internal fun CalendarEditor(
|
private fun CalendarEditor(
|
||||||
sessionKey: Int,
|
sessionKey: Int,
|
||||||
isNew: Boolean,
|
isNew: Boolean,
|
||||||
initialName: String,
|
initialName: String,
|
||||||
@@ -381,50 +360,121 @@ internal fun CalendarEditor(
|
|||||||
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
||||||
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
|
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
|
||||||
var confirmDelete by remember { mutableStateOf(false) }
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
val focusRequester = remember { FocusRequester() }
|
val dark = isSystemInDarkTheme()
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
|
||||||
val valid = name.isNotBlank()
|
Scaffold(
|
||||||
val commit = { if (valid) onSave(name.trim(), color, description.trim().ifEmpty { null }) }
|
modifier = Modifier
|
||||||
|
.predictiveBack(onBack = onClose)
|
||||||
FullScreenPicker(
|
.fillMaxSize()
|
||||||
title = stringResource(
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
if (isNew) R.string.calendars_new_title else R.string.calendars_edit_title,
|
topBar = {
|
||||||
),
|
TopAppBar(
|
||||||
onDismiss = onClose,
|
title = {
|
||||||
predictiveBack = true,
|
Text(
|
||||||
actions = {
|
stringResource(
|
||||||
Button(
|
if (isNew) R.string.calendars_new_title
|
||||||
onClick = commit,
|
else R.string.calendars_edit_title,
|
||||||
enabled = valid,
|
),
|
||||||
modifier = Modifier.padding(end = 12.dp),
|
)
|
||||||
) { Text(stringResource(R.string.event_edit_save)) }
|
},
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
) {
|
) { innerPadding ->
|
||||||
if (deleteLocked) {
|
Column(
|
||||||
ManagedNote()
|
modifier = Modifier
|
||||||
Spacer(Modifier.height(20.dp))
|
.padding(innerPadding)
|
||||||
}
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
CalendarNameField(
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
name = name,
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
color = color,
|
) {
|
||||||
focusRequester = focusRequester,
|
if (deleteLocked) {
|
||||||
onNameChange = { name = it },
|
EditorCard(
|
||||||
onImeAction = commit,
|
icon = Icons.Default.Info,
|
||||||
)
|
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
// Inside the picker: it is a Dialog, so a request made from the caller's
|
iconAtTop = true,
|
||||||
// composition can run before this field's node exists.
|
) {
|
||||||
// A new calendar opens with the keyboard up: naming it is the whole task.
|
Text(
|
||||||
LaunchedEffect(isNew) { if (isNew) focusRequester.requestFocus() }
|
text = stringResource(R.string.calendars_managed_delete_locked),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
Spacer(Modifier.height(20.dp))
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
EditorSectionLabel(stringResource(R.string.calendars_color_label))
|
)
|
||||||
CalendarColorGrid(selected = color, onSelect = { color = it })
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.height(20.dp))
|
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) {
|
||||||
EditorSectionLabel(stringResource(R.string.calendars_description_label))
|
InlineTextField(
|
||||||
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
value = name,
|
||||||
Box(Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(16.dp)) {
|
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,
|
||||||
|
) {
|
||||||
InlineTextField(
|
InlineTextField(
|
||||||
value = description,
|
value = description,
|
||||||
onValueChange = { description = it },
|
onValueChange = { description = it },
|
||||||
@@ -435,21 +485,20 @@ internal fun CalendarEditor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isNew && !deleteLocked) {
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
DeleteCalendarRow(onClick = { confirmDelete = true })
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (confirmDelete) {
|
if (confirmDelete) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { confirmDelete = false },
|
onDismissRequest = { confirmDelete = false },
|
||||||
title = { Text(stringResource(R.string.calendars_delete_confirm_title)) },
|
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 = {
|
confirmButton = {
|
||||||
TextButton(onClick = { confirmDelete = false; onDelete() }) {
|
TextButton(onClick = {
|
||||||
|
confirmDelete = false
|
||||||
|
onDelete()
|
||||||
|
}) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.event_detail_delete),
|
stringResource(R.string.event_detail_delete),
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
@@ -465,205 +514,6 @@ internal 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
|
* The row's supporting line: the states that make this calendar behave unlike a
|
||||||
* plain writable one (#76), then its own description.
|
* plain writable one (#76), then its own description.
|
||||||
@@ -706,6 +556,36 @@ private fun EnableSwitch(
|
|||||||
private fun dimIf(disabled: Boolean): Modifier =
|
private fun dimIf(disabled: Boolean): Modifier =
|
||||||
if (disabled) Modifier.alpha(0.38f) else 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
|
* One collapsible calendar group rendered as a connected card. The header row is
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -25,7 +24,6 @@ import kotlinx.coroutines.flow.catch
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -58,22 +56,6 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
initialValue = emptyList(),
|
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. */
|
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
||||||
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
||||||
settingsPrefs.autoBackupEnabled,
|
settingsPrefs.autoBackupEnabled,
|
||||||
|
|||||||
@@ -56,14 +56,12 @@ fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
|
|||||||
* account comes from when another account shares the name (#77).
|
* account comes from when another account shares the name (#77).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun accountGroupTitle(group: CalendarAccountGroup): String {
|
fun accountGroupTitle(group: CalendarAccountGroup): String =
|
||||||
val label = group.label.ifBlank { stringResource(R.string.calendar_unnamed) }
|
if (!group.ambiguous) {
|
||||||
return if (!group.ambiguous) {
|
group.label
|
||||||
label
|
|
||||||
} else {
|
} else {
|
||||||
stringResource(R.string.calendars_account_from_source, label, sourceAppName(group.accountType))
|
stringResource(R.string.calendars_account_from_source, group.label, sourceAppName(group.accountType))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The human name of the app backing [accountType], falling back to the raw
|
* The human name of the app backing [accountType], falling back to the raw
|
||||||
|
|||||||
@@ -32,68 +32,6 @@ val BLOCK_TEXT_PADDING = 4.dp
|
|||||||
/** The same, above and below — what a block's height has to pay before any text. */
|
/** The same, above and below — what a block's height has to pay before any text. */
|
||||||
val BLOCK_TEXT_INSET = 2.dp
|
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. */
|
/** Most lines a time label may wrap over before it is worth more than a title line. */
|
||||||
const val MAX_TIME_LINES = 2
|
const val MAX_TIME_LINES = 2
|
||||||
|
|
||||||
@@ -132,9 +70,9 @@ fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
|
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
|
||||||
val timeLineHeight = rememberTrimmedLineHeight(
|
val timeLineHeight = with(LocalDensity.current) {
|
||||||
MaterialTheme.typography.labelSmall.asEventTime(),
|
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||||
)
|
}
|
||||||
return if (spare >= timeLineHeight) {
|
return if (spare >= timeLineHeight) {
|
||||||
blockTextLines(
|
blockTextLines(
|
||||||
text = label,
|
text = label,
|
||||||
@@ -170,10 +108,8 @@ fun BlockTitle(
|
|||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
style = rememberPackedLines(
|
style = MaterialTheme.typography.labelMedium
|
||||||
MaterialTheme.typography.labelMedium
|
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
|
||||||
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
|
|
||||||
),
|
|
||||||
maxLines = maxLines,
|
maxLines = maxLines,
|
||||||
overflow = overflow.overflow,
|
overflow = overflow.overflow,
|
||||||
softWrap = overflow.softWrap,
|
softWrap = overflow.softWrap,
|
||||||
@@ -205,8 +141,6 @@ fun BlockTimeLabel(
|
|||||||
MaterialTheme.motionScheme.fastEffectsSpec()
|
MaterialTheme.motionScheme.fastEffectsSpec()
|
||||||
}
|
}
|
||||||
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
|
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
|
||||||
// Regular weight against the title's medium above it (#219).
|
|
||||||
val style = rememberPackedLines(MaterialTheme.typography.labelSmall.asEventTime())
|
|
||||||
Crossfade(
|
Crossfade(
|
||||||
targetState = label,
|
targetState = label,
|
||||||
animationSpec = spec,
|
animationSpec = spec,
|
||||||
@@ -215,7 +149,8 @@ fun BlockTimeLabel(
|
|||||||
) { text ->
|
) { text ->
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = style,
|
// Regular weight against the title's medium above it (#219).
|
||||||
|
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
||||||
maxLines = maxLines,
|
maxLines = maxLines,
|
||||||
overflow = overflow.overflow,
|
overflow = overflow.overflow,
|
||||||
softWrap = overflow.softWrap,
|
softWrap = overflow.softWrap,
|
||||||
|
|||||||
@@ -4,36 +4,19 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@@ -43,150 +26,52 @@ import de.jeanlucmakiola.calendula.domain.FailureReason
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-screen failure state shared by every calendar screen (spec §7).
|
* Full-screen failure state shared by every calendar screen (spec §7).
|
||||||
* A tonal icon, one headline, one supporting line and one recovery action —
|
* One explanation line + one recovery action, never a toast.
|
||||||
* never a toast.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarFailure(
|
fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
|
||||||
reason: FailureReason,
|
|
||||||
onRetry: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val manageCalendars = LocalManageCalendars.current
|
val manageCalendars = LocalManageCalendars.current
|
||||||
val copy = failureCopy(reason)
|
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 onAction: () -> Unit = when (reason) {
|
val onAction: () -> Unit = when (reason) {
|
||||||
FailureReason.NoCalendarsConfigured -> {
|
FailureReason.NoCalendarsConfigured -> {
|
||||||
{ context.startCalendarSetup() }
|
{ context.startCalendarSetup() }
|
||||||
}
|
}
|
||||||
FailureReason.AllCalendarsHidden,
|
FailureReason.AllCalendarsHidden -> manageCalendars ?: onRetry
|
||||||
FailureReason.NoImportTarget,
|
|
||||||
-> manageCalendars ?: onRetry
|
|
||||||
else -> onRetry
|
else -> onRetry
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 32.dp, vertical = 24.dp),
|
.padding(32.dp),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
FailureIcon(icon = copy.icon, isError = copy.isError)
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(copy.title),
|
text = stringResource(titleRes),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH),
|
|
||||||
)
|
)
|
||||||
copy.body?.let { body ->
|
Spacer(Modifier.height(24.dp))
|
||||||
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) {
|
FilledTonalButton(onClick = onAction) {
|
||||||
Text(stringResource(copy.action))
|
Text(stringResource(actionRes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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
|
* Opens Settings → Calendars, the only screen that can switch a calendar back
|
||||||
* on. Null outside the calendar host — screens without it never raise the
|
* on. Null outside the calendar host — screens without it never raise the
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ private fun CalendarPickerGroup(
|
|||||||
calendars.forEachIndexed { index, calendar ->
|
calendars.forEachIndexed { index, calendar ->
|
||||||
val isSelected = calendar.id == selectedId
|
val isSelected = calendar.id == selectedId
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
title = calendar.displayName,
|
||||||
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
|
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
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() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -83,10 +83,8 @@ fun calendarSlideTransition(
|
|||||||
initialContentExit =
|
initialContentExit =
|
||||||
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
|
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
|
||||||
// AnimatedContent clips to the animating container by default, which
|
// AnimatedContent clips to the animating container by default, which
|
||||||
// shears the pages against the viewport edge as they pass. Left off even
|
// shears the pages against the viewport edge as they pass. There is no
|
||||||
// where the two pages differ in height — the split grid stands as many
|
// size change here to contain — both pages are the same grid.
|
||||||
// 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),
|
sizeTransform = SizeTransform(clip = false),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -790,15 +790,20 @@ private fun DragCopy(
|
|||||||
val width = with(density) { sizePx.width.toDp() }
|
val width = with(density) { sizePx.width.toDp() }
|
||||||
val height = with(density) { sizePx.height.toDp() }
|
val height = with(density) { sizePx.height.toDp() }
|
||||||
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
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
|
// 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 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
|
// the hour gutter down the side still says where the copy sits, so the
|
||||||
// range is the half that can afford to go.
|
// range is the half that can afford to go.
|
||||||
val metrics = rememberBlockTextMetrics(height)
|
val available = height - BLOCK_TEXT_INSET * 2
|
||||||
val allowed = titleLines.coerceAtMost(metrics.titleBudget(metrics.available))
|
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(0)
|
||||||
|
val allowed = titleLines.coerceAtMost(titleBudget)
|
||||||
// Re-measured at the copy's own width rather than spent on the source's
|
// 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
|
// 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
|
// copy is a whole column, so the source's second line is one the copy never
|
||||||
@@ -813,10 +818,10 @@ private fun DragCopy(
|
|||||||
max = allowed,
|
max = allowed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val left = metrics.available - metrics.titleHeight(lines)
|
val left = available - titleLineHeight * lines
|
||||||
val showTime = label != null && left >= metrics.timeLine
|
val showTime = label != null && left >= timeLineHeight
|
||||||
val timeMaxLines = if (showTime) {
|
val timeMaxLines = if (showTime) {
|
||||||
blockTimeLines(label!!, textWidth, left - metrics.timeLine)
|
blockTimeLines(label!!, textWidth, left - timeLineHeight)
|
||||||
} else {
|
} else {
|
||||||
1
|
1
|
||||||
}
|
}
|
||||||
@@ -852,7 +857,7 @@ private fun DragCopy(
|
|||||||
clip = false
|
clip = false
|
||||||
}
|
}
|
||||||
.eventSurface(paint, shape, cuts)
|
.eventSurface(paint, shape, cuts)
|
||||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset),
|
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET),
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
if (lines > 0) {
|
if (lines > 0) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import androidx.compose.ui.input.pointer.PointerEventPass
|
|||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.ceil
|
||||||
import kotlin.math.floor
|
import kotlin.math.floor
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -196,26 +197,18 @@ fun rememberTimelinePinchZoom(
|
|||||||
* names, jumping the whole column as a pinch drifts across each half pixel.
|
* 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.
|
* Pinning the hour to whole pixels keeps every part of the timeline on one grid.
|
||||||
*
|
*
|
||||||
* The ceiling is pulled onto that grid too, downwards, so it stays a height the
|
* The bounds themselves are pulled onto that grid too, each in the direction
|
||||||
* pinch can actually land on.
|
* 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
|
||||||
* [fillPx] is deliberately *not* rounded (#290). It is the one height the whole
|
* pinch can be held against but never actually land on, and the difference feeds
|
||||||
* day exactly fills the viewport at, and it is the same value
|
* the focal anchor a scroll correction on every frame the fingers sit still.
|
||||||
* [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 =
|
internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float =
|
||||||
// Filling the viewport wins over the ceiling: on a screen tall enough for
|
// 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.
|
// the two to disagree, dead space is the worse of the two failures.
|
||||||
target.roundToInt().toFloat()
|
target.roundToInt().toFloat()
|
||||||
.coerceAtMost(floor(maxPx))
|
.coerceAtMost(floor(maxPx))
|
||||||
.coerceAtLeast(fillPx)
|
.coerceAtLeast(ceil(fillPx))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The scroll offset that keeps the moment under [centroidY] under it after the
|
* The scroll offset that keeps the moment under [centroidY] under it after the
|
||||||
|
|||||||
@@ -83,9 +83,9 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
|
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_PADDING
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET
|
||||||
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
|
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.blockTimeLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||||
@@ -115,7 +115,6 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
|||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
||||||
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
@@ -141,8 +140,7 @@ import kotlin.time.Clock
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/** One lane of the all-day strip, sized to a trimmed bar line (#190). */
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_ROW_HEIGHT = 20.dp
|
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
|
|
||||||
/** Total all-day strip height for the day (0 when there are no all-day events). */
|
/** Total all-day strip height for the day (0 when there are no all-day events). */
|
||||||
@@ -531,7 +529,7 @@ private fun AllDayBar(
|
|||||||
val titleOverflow = eventTitleOverflow()
|
val titleOverflow = eventTitleOverflow()
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelSmall.trimmedLines(),
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = titleOverflow.overflow,
|
overflow = titleOverflow.overflow,
|
||||||
softWrap = titleOverflow.softWrap,
|
softWrap = titleOverflow.softWrap,
|
||||||
@@ -765,24 +763,30 @@ private fun EventBlock(
|
|||||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||||
minToHm(block.endMin, use24Hour, locale)
|
minToHm(block.endMin, use24Hour, locale)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val metrics = rememberBlockTextMetrics(height)
|
val titleLineHeight = with(density) {
|
||||||
|
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||||
|
}
|
||||||
|
val timeLineHeight = with(density) {
|
||||||
|
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||||
|
}
|
||||||
// A block that cannot afford both lines spends its space on the title, and
|
// 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.
|
// 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
|
// 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.
|
// half-hour block the user has pinched open to three times the room it needs.
|
||||||
val showTime = metrics.available >= metrics.titleLine + metrics.timeLine
|
val available = height - BLOCK_TEXT_INSET * 2
|
||||||
val showTitle = metrics.fitsTitle
|
val showTime = available >= titleLineHeight + timeLineHeight
|
||||||
|
val showTitle = available >= titleLineHeight
|
||||||
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
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
|
// 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
|
// 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
|
// 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).
|
// title re-wrapped the moment the block was lifted (#267).
|
||||||
val titleBudget = metrics.titleBudget(metrics.available).coerceAtLeast(1)
|
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(1)
|
||||||
val titleMaxLines = if (showTime) 1 else titleBudget.coerceAtMost(2)
|
val titleMaxLines = if (showTime) 1 else titleBudget.coerceAtMost(2)
|
||||||
// On a day column — wide enough for "09:30–11:00" several times over — the
|
// 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.
|
// range never needs the second line, until lanes cut the column down.
|
||||||
val spare = metrics.available - metrics.titleHeight(titleMaxLines) -
|
val spare = available - titleLineHeight * titleMaxLines -
|
||||||
if (showTime) metrics.timeLine else 0.dp
|
if (showTime) timeLineHeight else 0.dp
|
||||||
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
|
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
|
||||||
val paint = eventPaint(block.event, dark)
|
val paint = eventPaint(block.event, dark)
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
@@ -823,7 +827,7 @@ private fun EventBlock(
|
|||||||
// After clickable, so it is the inner node and wins the main pass;
|
// After clickable, so it is the inner node and wins the main pass;
|
||||||
// the tap still works, since a drag consumes the up.
|
// the tap still works, since a drag consumes the up.
|
||||||
.then(dragModifier)
|
.then(dragModifier)
|
||||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
|
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET)
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = "$title, $timeLabel"
|
contentDescription = "$title, $timeLabel"
|
||||||
if (moveAction != null) customActions = listOf(moveAction)
|
if (moveAction != null) customActions = listOf(moveAction)
|
||||||
|
|||||||
@@ -302,7 +302,6 @@ fun EventDetailScreen(
|
|||||||
is EventDetailUiState.Failure -> CalendarFailure(
|
is EventDetailUiState.Failure -> CalendarFailure(
|
||||||
reason = s.reason,
|
reason = s.reason,
|
||||||
onRetry = viewModel::retry,
|
onRetry = viewModel::retry,
|
||||||
modifier = contentModifier,
|
|
||||||
)
|
)
|
||||||
is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier)
|
is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier)
|
||||||
}
|
}
|
||||||
@@ -527,8 +526,7 @@ private fun EventDetailContent(
|
|||||||
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = state.calendarName?.ifBlank { stringResource(R.string.calendar_unnamed) }
|
text = state.calendarName ?: stringResource(R.string.event_detail_calendar_unknown),
|
||||||
?: stringResource(R.string.event_detail_calendar_unknown),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -620,9 +620,7 @@ private fun EventEditContent(
|
|||||||
Text(
|
Text(
|
||||||
text = stringResource(
|
text = stringResource(
|
||||||
R.string.event_edit_managed_hint,
|
R.string.event_edit_managed_hint,
|
||||||
selectedCalendar?.displayName
|
selectedCalendar?.displayName.orEmpty(),
|
||||||
?.ifBlank { stringResource(R.string.calendar_unnamed) }
|
|
||||||
.orEmpty(),
|
|
||||||
),
|
),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
@@ -721,7 +719,6 @@ private fun EventEditContent(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = selectedCalendar?.displayName
|
text = selectedCalendar?.displayName
|
||||||
?.ifBlank { stringResource(R.string.calendar_unnamed) }
|
|
||||||
?: stringResource(R.string.event_edit_error_no_calendar),
|
?: stringResource(R.string.event_edit_error_no_calendar),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = if (selectedCalendar == null) {
|
color = if (selectedCalendar == null) {
|
||||||
|
|||||||
@@ -62,16 +62,15 @@ private fun FilterList(
|
|||||||
) {
|
) {
|
||||||
Column(modifier = modifier.fillMaxWidth()) {
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
groups.forEach { group ->
|
groups.forEach { group ->
|
||||||
val account = group.account.ifBlank { stringResource(R.string.calendar_unnamed) }
|
|
||||||
Text(
|
Text(
|
||||||
text = if (group.ambiguous) {
|
text = if (group.ambiguous) {
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.calendars_account_from_source,
|
R.string.calendars_account_from_source,
|
||||||
account,
|
group.account,
|
||||||
sourceAppName(group.accountType),
|
sourceAppName(group.accountType),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
account
|
group.account
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
@@ -84,7 +83,7 @@ private fun FilterList(
|
|||||||
)
|
)
|
||||||
group.calendars.forEachIndexed { index, cal ->
|
group.calendars.forEachIndexed { index, cal ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = cal.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
title = cal.displayName,
|
||||||
position = positionOf(index, group.calendars.size),
|
position = positionOf(index, group.calendars.size),
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
leading = { CalendarColorChip(cal.color) },
|
leading = { CalendarColorChip(cal.color) },
|
||||||
|
|||||||
@@ -73,19 +73,19 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
|
|||||||
@Composable
|
@Composable
|
||||||
fun ImportScreen(
|
fun ImportScreen(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
session: Int,
|
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onOpenSingle: (EventForm) -> Unit,
|
onOpenSingle: (EventForm) -> Unit,
|
||||||
forceMany: Boolean = false,
|
forceMany: Boolean = false,
|
||||||
onManageCalendars: (() -> Unit)? = null,
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
viewModel: ImportViewModel = hiltViewModel(),
|
// 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()),
|
||||||
) {
|
) {
|
||||||
// hiltViewModel() resolves to the Activity's store and is retained across
|
LaunchedEffect(uri) { viewModel.load(uri, forceMany) }
|
||||||
// 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()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// A single event isn't shown here — it opens the create form for review.
|
// A single event isn't shown here — it opens the create form for review.
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
|||||||
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -70,26 +69,18 @@ class ImportViewModel @Inject constructor(
|
|||||||
private val parser = IcsParser()
|
private val parser = IcsParser()
|
||||||
private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Loading)
|
private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Loading)
|
||||||
val state: StateFlow<ImportUiState> = _state.asStateFlow()
|
val state: StateFlow<ImportUiState> = _state.asStateFlow()
|
||||||
private var loadedSession: Int? = null
|
private var started = false
|
||||||
private var loadJob: Job? = null
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read + parse [uri] once per [session]; recomposition (and a rotation,
|
* Read + parse [uri] once; subsequent calls (recomposition) are ignored.
|
||||||
* 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
|
* 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 —
|
* through the bulk picker + summary rather than the prefilled create form —
|
||||||
* a restore is "bring back a backup", not "add this one event".
|
* a restore is "bring back a backup", not "add this one event".
|
||||||
*/
|
*/
|
||||||
fun load(uri: Uri, forceMany: Boolean = false, session: Int = 0) {
|
fun load(uri: Uri, forceMany: Boolean = false) {
|
||||||
if (loadedSession == session) return
|
if (started) return
|
||||||
loadedSession = session
|
started = true
|
||||||
loadJob?.cancel()
|
viewModelScope.launch {
|
||||||
_state.value = ImportUiState.Loading
|
|
||||||
loadJob = viewModelScope.launch {
|
|
||||||
val parsed = withContext(io) {
|
val parsed = withContext(io) {
|
||||||
importer.readText(uri)?.let(parser::parse)
|
importer.readText(uri)?.let(parser::parse)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,16 +10,10 @@ import androidx.compose.ui.unit.dp
|
|||||||
internal val CELL_GAP = 2.dp
|
internal val CELL_GAP = 2.dp
|
||||||
|
|
||||||
/** Padding between a month chip's edge and its text. */
|
/** Padding between a month chip's edge and its text. */
|
||||||
internal val MONTH_CHIP_TEXT_PADDING = 3.dp
|
internal val MONTH_CHIP_TEXT_PADDING = 4.dp
|
||||||
|
|
||||||
/**
|
/** A chip's own inset inside its day cell, on top of the cell's gap. */
|
||||||
* A chip's own inset inside its day cell. The cell's gap and no more: a day
|
internal val MONTH_CHIP_INSET = CELL_GAP + 1.dp
|
||||||
* 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. */
|
/** 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
|
internal val MONTH_CHIP_CHROME = (MONTH_CHIP_INSET + MONTH_CHIP_TEXT_PADDING) * 2f
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ import androidx.compose.ui.platform.LocalDensity
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -154,7 +153,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
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.monthBarShape
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
@@ -746,7 +744,7 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
|
|||||||
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
|
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
// Reserve the gutter so the weekday labels stay over their day columns.
|
// Reserve the gutter so the weekday labels stay over their day columns.
|
||||||
if (showWeekNumbers) Spacer(Modifier.width(rememberWeekNumberGutter()))
|
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER))
|
||||||
days.forEach { dow ->
|
days.forEach { dow ->
|
||||||
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
|
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
|
||||||
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
|
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
|
||||||
@@ -764,34 +762,9 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
|
|||||||
|
|
||||||
private val EVENT_ROW_HEIGHT = 20.dp
|
private val EVENT_ROW_HEIGHT = 20.dp
|
||||||
private val DAY_NUMBER_HEIGHT = 22.dp
|
private val DAY_NUMBER_HEIGHT = 22.dp
|
||||||
/** Padding between the week-number pill's edge and the number inside it. */
|
/** Width of the optional left calendar-week gutter (#25); narrow, since it only
|
||||||
private val WEEK_NUMBER_PADDING = 6.dp
|
* seats a one- or two-digit week number in a full-height tonal pill. */
|
||||||
|
private val WEEK_NUMBER_GUTTER = 40.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 DAY_NUMBER_GAP = 4.dp
|
||||||
private val CELL_TOP_PADDING = 6.dp
|
private val CELL_TOP_PADDING = 6.dp
|
||||||
/** Named separately because the split style's selection outline draws its own
|
/** Named separately because the split style's selection outline draws its own
|
||||||
@@ -801,79 +774,14 @@ private val CELL_SHAPE = RoundedCornerShape(CELL_CORNER)
|
|||||||
|
|
||||||
/** Width of the split style's selected-day outline. */
|
/** Width of the split style's selected-day outline. */
|
||||||
private val SPLIT_SELECTION_STROKE = 1.5.dp
|
private val SPLIT_SELECTION_STROKE = 1.5.dp
|
||||||
/**
|
/** Lanes of bars/pills a day cell draws before the rest become overflow dots. */
|
||||||
* Dots a split-style cell draws. Fixed, unlike the paged grid's measured cap:
|
internal const val MAX_EVENT_ROWS = 3
|
||||||
* 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
|
* 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
|
* however many rows the month has; a scrolling stream has no such bound, so it
|
||||||
* fixes one — close to what a five-row month gets on a typical phone. How many
|
* fixes a height that seats the day number plus [MAX_EVENT_ROWS] event rows —
|
||||||
* chips that seats is [MonthWeek.laneCapFor]'s answer like anywhere else, so a
|
* close to what a five-row month gets on a typical phone.
|
||||||
* week that does not overflow fills the band rather than holding a lane back.
|
|
||||||
*/
|
*/
|
||||||
private val CONTINUOUS_ROW_HEIGHT = 112.dp
|
private val CONTINUOUS_ROW_HEIGHT = 112.dp
|
||||||
|
|
||||||
@@ -885,11 +793,6 @@ private val CONTINUOUS_ROW_HEIGHT = 112.dp
|
|||||||
*/
|
*/
|
||||||
private val CONTINUOUS_MONTH_GAP = 20.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. */
|
/** Gap between the weekday header and the seamless stream's first week row. */
|
||||||
private val DENSE_HEADER_GAP = 4.dp
|
private val DENSE_HEADER_GAP = 4.dp
|
||||||
|
|
||||||
@@ -902,43 +805,34 @@ internal fun MonthGrid(
|
|||||||
/** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */
|
/** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */
|
||||||
selected: LocalDate? = null,
|
selected: LocalDate? = null,
|
||||||
) {
|
) {
|
||||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
Column(
|
||||||
// The rows divide whatever the viewport leaves once the Column's own
|
modifier = Modifier
|
||||||
// padding and the gaps between them are paid, so how many chips a cell
|
.fillMaxSize()
|
||||||
// can seat is only knowable here.
|
// Match the weekday header's inset so day cells sit under their
|
||||||
val rows = state.weeks.size.coerceAtLeast(1)
|
// labels, and so the week-number gutter's centre lines up with the
|
||||||
val rowHeight =
|
// top bar's hamburger (4dp bar inset + 24dp half icon button).
|
||||||
(maxHeight - GRID_VERTICAL_PADDING * 2 - GRID_ROW_GAP * (rows - 1)) / rows
|
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
|
||||||
Column(
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
modifier = Modifier
|
) {
|
||||||
.fillMaxSize()
|
val month = state.month
|
||||||
// Match the weekday header's inset so day cells sit under their
|
// Once per grid: the value depends on the typography, the density, the
|
||||||
// labels, and so the week-number gutter's centre lines up with the
|
// locale and the 24-hour setting, none of which vary by row (#219).
|
||||||
// top bar's hamburger (4dp bar inset + 24dp half icon button).
|
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||||
.padding(horizontal = AppBarSpacing.Inset, vertical = GRID_VERTICAL_PADDING),
|
state.weeks.forEach { week ->
|
||||||
verticalArrangement = Arrangement.spacedBy(GRID_ROW_GAP),
|
MonthWeekRow(
|
||||||
) {
|
week = week,
|
||||||
val month = state.month
|
today = state.today,
|
||||||
// Once per grid: the value depends on the typography, the density, the
|
zone = state.zone,
|
||||||
// locale and the 24-hour setting, none of which vary by row (#219).
|
timeChipWidth = timeChipWidth,
|
||||||
val timeChipWidth = rememberMonthTimeChipWidth()
|
inMonth = { it.month == month.month && it.year == month.year },
|
||||||
state.weeks.forEach { week ->
|
showWeekNumbers = showWeekNumbers,
|
||||||
MonthWeekRow(
|
onOpenDay = onOpenDay,
|
||||||
week = week,
|
onEventClick = onEventClick,
|
||||||
today = state.today,
|
selected = selected,
|
||||||
zone = state.zone,
|
modifier = Modifier
|
||||||
timeChipWidth = timeChipWidth,
|
.fillMaxWidth()
|
||||||
rowHeight = rowHeight,
|
.weight(1f),
|
||||||
inMonth = { it.month == month.month && it.year == month.year },
|
)
|
||||||
showWeekNumbers = showWeekNumbers,
|
|
||||||
onOpenDay = onOpenDay,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
selected = selected,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1036,7 +930,6 @@ private fun ContinuousMonthBlock(
|
|||||||
today = today,
|
today = today,
|
||||||
zone = zone,
|
zone = zone,
|
||||||
timeChipWidth = timeChipWidth,
|
timeChipWidth = timeChipWidth,
|
||||||
rowHeight = CONTINUOUS_ROW_HEIGHT,
|
|
||||||
inMonth = { it.month == month.month && it.year == month.year },
|
inMonth = { it.month == month.month && it.year == month.year },
|
||||||
// The block owns its month alone: a day from either
|
// The block owns its month alone: a day from either
|
||||||
// neighbour is left out entirely rather than dimmed.
|
// neighbour is left out entirely rather than dimmed.
|
||||||
@@ -1131,7 +1024,6 @@ internal fun DenseMonthGrid(
|
|||||||
today = state.today,
|
today = state.today,
|
||||||
zone = state.zone,
|
zone = state.zone,
|
||||||
timeChipWidth = timeChipWidth,
|
timeChipWidth = timeChipWidth,
|
||||||
rowHeight = CONTINUOUS_ROW_HEIGHT,
|
|
||||||
// Every day in the stream belongs to a month equally — there
|
// Every day in the stream belongs to a month equally — there
|
||||||
// is no "other month" to recede here.
|
// is no "other month" to recede here.
|
||||||
inMonth = { true },
|
inMonth = { true },
|
||||||
@@ -1249,10 +1141,10 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp
|
|||||||
* lists whatever day is selected — and a downward drag trades the pane away for
|
* 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 full paged grid, an upward one brings it back (#53).
|
||||||
*
|
*
|
||||||
* The grid slides between months like the paged style, and stands only as many
|
* The grid slides between months like the paged style, which it can only do
|
||||||
* rows tall as its own month spans (#162). A swipe between a five-row month and
|
* because it always reserves [SPLIT_GRID_ROWS] rows. Sized to its own month it
|
||||||
* a six-row one therefore moves the pane by a row as well as swapping the grid;
|
* stood 4–6 rows tall, so every swipe shunted the pane up or down by a row on
|
||||||
* the row it hands back is worth more to the pane than a still edge is.
|
* top of swapping the grid — the pane now holds still and only the grid moves.
|
||||||
*
|
*
|
||||||
* Expansion is deliberately **not** a stored preference. It is a way to look at
|
* 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
|
* the month you are on, not a fourth style; persisted, someone would expand it
|
||||||
@@ -1545,10 +1437,17 @@ private fun SplitExpandHandle(
|
|||||||
*/
|
*/
|
||||||
private val SPLIT_ROW_HEIGHT = 46.dp
|
private val SPLIT_ROW_HEIGHT = 46.dp
|
||||||
private val SPLIT_DOT_SIZE = 5.dp
|
private val SPLIT_DOT_SIZE = 5.dp
|
||||||
// Dots are capped by SPLIT_DOT_LANES, not a constant of their own: they stand for
|
// Dots are capped by MAX_EVENT_ROWS, 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
|
// 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).
|
// 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
|
* The expand handle: M3's drag-handle pill (32×4dp), in a row tall enough to be a
|
||||||
* comfortable tap target on its own.
|
* comfortable tap target on its own.
|
||||||
@@ -1561,11 +1460,6 @@ private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp
|
|||||||
* The split style's grid (#53): the month compressed to day numbers and event
|
* The split style's grid (#53): the month compressed to day numbers and event
|
||||||
* dots, with the selected day listed underneath by [SplitDayPane].
|
* 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
|
* 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
|
* 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.
|
* the layout. The full Day view stays one tap away on the pane's date header.
|
||||||
@@ -1592,7 +1486,7 @@ internal fun SplitMonthGrid(
|
|||||||
WeekNumberGutter(
|
WeekNumberGutter(
|
||||||
weekStart = week.days.first(),
|
weekStart = week.days.first(),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(rememberWeekNumberGutter())
|
.width(WEEK_NUMBER_GUTTER)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1600,11 +1494,11 @@ internal fun SplitMonthGrid(
|
|||||||
val inMonth = day.month == month.month && day.year == month.year
|
val inMonth = day.month == month.month && day.year == month.year
|
||||||
// Seated by lane rather than gathered by colour, so each dot
|
// Seated by lane rather than gathered by colour, so each dot
|
||||||
// is the event the expanded grid draws in that same lane.
|
// is the event the expanded grid draws in that same lane.
|
||||||
val seated = week.laneEvents(col, day, SPLIT_DOT_LANES)
|
val seated = week.laneEvents(col, day, MAX_EVENT_ROWS)
|
||||||
SplitDayCell(
|
SplitDayCell(
|
||||||
date = day,
|
date = day,
|
||||||
events = seated,
|
events = seated,
|
||||||
hidden = week.overflowEvents(col, day, SPLIT_DOT_LANES),
|
hidden = week.overflowEvents(col, day, MAX_EVENT_ROWS),
|
||||||
isToday = day == state.today,
|
isToday = day == state.today,
|
||||||
// A page marks only the days its own month owns. Paging
|
// A page marks only the days its own month owns. Paging
|
||||||
// moves the selection before this month's replacement
|
// moves the selection before this month's replacement
|
||||||
@@ -1624,11 +1518,17 @@ 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 [SPLIT_DOT_LANES] lane-seated event dots.
|
* One compact day: its number over up to [MAX_EVENT_ROWS] lane-seated event dots.
|
||||||
*
|
*
|
||||||
* Selection and today are deliberately different signals — a tinted, outlined
|
* Selection and today are deliberately different signals — a tinted, outlined
|
||||||
* cell versus the filled circle the other views already use for today — so the
|
* cell versus the filled circle the other views already use for today — so the
|
||||||
@@ -2002,7 +1902,7 @@ private fun rememberSkeletonPulse(): Float {
|
|||||||
* One week of the grid. Bars (all-day / multi-day) are positioned absolutely so
|
* 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
|
* 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
|
* events sit beneath them as filled pills in their own cell. The cap is
|
||||||
* [SPLIT_DOT_LANES] rows of bars+pills, then a "+N" dot indicator per day.
|
* [MAX_EVENT_ROWS] 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".
|
* A transparent per-day layer on top turns a tap into "open that day".
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
@@ -2013,8 +1913,6 @@ private fun MonthWeekRow(
|
|||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
/** The narrowest chip that may carry a start time, measured once per grid (#219). */
|
/** The narrowest chip that may carry a start time, measured once per grid (#219). */
|
||||||
timeChipWidth: Dp,
|
timeChipWidth: Dp,
|
||||||
/** The height this row was given, which decides its lanes — see [laneCapFor]. */
|
|
||||||
rowHeight: Dp,
|
|
||||||
inMonth: (LocalDate) -> Boolean,
|
inMonth: (LocalDate) -> Boolean,
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
@@ -2031,10 +1929,8 @@ private fun MonthWeekRow(
|
|||||||
selected: LocalDate? = null,
|
selected: LocalDate? = null,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val overflowRow = rememberOverflowRowHeight()
|
|
||||||
val laneCap = week.laneCapFor(rowHeight, overflowRow)
|
|
||||||
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||||
val shownLanes = laneCount.coerceAtMost(laneCap)
|
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
||||||
val morphing = morphInFlight()
|
val morphing = morphInFlight()
|
||||||
// Every chip's start time for this row at once, and only when the row's own
|
// 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
|
// inputs change: formatting is a parsed pattern per call, and the dim cutoff
|
||||||
@@ -2087,9 +1983,9 @@ private fun MonthWeekRow(
|
|||||||
band = bandCoordinates[0],
|
band = bandCoordinates[0],
|
||||||
columnWidthPx = cell.size.width / 7f,
|
columnWidthPx = cell.size.width / 7f,
|
||||||
laneHeightPx = rowHeightPx,
|
laneHeightPx = rowHeightPx,
|
||||||
laneCount = laneCap,
|
laneCount = MAX_EVENT_ROWS,
|
||||||
isRtl = isRtl,
|
isRtl = isRtl,
|
||||||
chipAt = { col, lane -> week.chipAt(col, lane, laneCap) },
|
chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) },
|
||||||
chipStart = { col, lane -> week.chipStartCol(col, lane) },
|
chipStart = { col, lane -> week.chipStartCol(col, lane) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -2111,7 +2007,7 @@ private fun MonthWeekRow(
|
|||||||
WeekNumberGutter(
|
WeekNumberGutter(
|
||||||
weekStart = week.days.first(),
|
weekStart = week.days.first(),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(rememberWeekNumberGutter())
|
.width(WEEK_NUMBER_GUTTER)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2130,7 +2026,6 @@ private fun MonthWeekRow(
|
|||||||
controller = dragController,
|
controller = dragController,
|
||||||
band = bandCoordinates,
|
band = bandCoordinates,
|
||||||
rowHeightPx = rowHeightPx,
|
rowHeightPx = rowHeightPx,
|
||||||
laneCap = laneCap,
|
|
||||||
isRtl = isRtl,
|
isRtl = isRtl,
|
||||||
chipTimes = chipTimes,
|
chipTimes = chipTimes,
|
||||||
),
|
),
|
||||||
@@ -2139,11 +2034,6 @@ private fun MonthWeekRow(
|
|||||||
// What a chip has to spend, against the [timeChipWidth] a start time
|
// What a chip has to spend, against the [timeChipWidth] a start time
|
||||||
// costs it (#219).
|
// costs it (#219).
|
||||||
val colW = maxWidth / 7
|
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
|
// Per-day background pills — same surfaceContainer rounded surface the
|
||||||
// week/day views use, so the three views share one visual language.
|
// week/day views use, so the three views share one visual language.
|
||||||
@@ -2286,7 +2176,7 @@ private fun MonthWeekRow(
|
|||||||
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
|
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
|
||||||
.map { it.lane }
|
.map { it.lane }
|
||||||
.toSet()
|
.toSet()
|
||||||
val freeSlots = (0 until laneCap).filter { it !in occupied }
|
val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied }
|
||||||
val pillsShown = timed.take(freeSlots.size)
|
val pillsShown = timed.take(freeSlots.size)
|
||||||
pillsShown.forEachIndexed { i, ev ->
|
pillsShown.forEachIndexed { i, ev ->
|
||||||
MonthBar(
|
MonthBar(
|
||||||
@@ -2320,20 +2210,8 @@ private fun MonthWeekRow(
|
|||||||
events = hiddenEvents,
|
events = hiddenEvents,
|
||||||
total = hidden,
|
total = hidden,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
rowHeight = overflowRow,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
// After the chip lanes, but never past the
|
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
|
||||||
// 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))
|
.morphBounds(MonthMorphKey.Overflow(d))
|
||||||
.width(colW)
|
.width(colW)
|
||||||
.padding(horizontal = 3.dp),
|
.padding(horizontal = 3.dp),
|
||||||
@@ -2408,7 +2286,6 @@ private fun MonthWeekRow(
|
|||||||
bandCoordinates,
|
bandCoordinates,
|
||||||
),
|
),
|
||||||
rowHeightPx = rowHeightPx,
|
rowHeightPx = rowHeightPx,
|
||||||
laneCap = laneCap,
|
|
||||||
)
|
)
|
||||||
if (chip != null) onEventClick(chip) else onOpenDay(d)
|
if (chip != null) onEventClick(chip) else onOpenDay(d)
|
||||||
},
|
},
|
||||||
@@ -2450,12 +2327,11 @@ internal fun MonthWeek.chipAtCellY(
|
|||||||
cellY: Float,
|
cellY: Float,
|
||||||
bandTopInCell: Float?,
|
bandTopInCell: Float?,
|
||||||
rowHeightPx: Float,
|
rowHeightPx: Float,
|
||||||
laneCap: Int,
|
|
||||||
): EventInstance? {
|
): EventInstance? {
|
||||||
if (bandTopInCell == null || rowHeightPx <= 0f) return null
|
if (bandTopInCell == null || rowHeightPx <= 0f) return null
|
||||||
val bandY = cellY - bandTopInCell
|
val bandY = cellY - bandTopInCell
|
||||||
if (bandY < 0f) return null
|
if (bandY < 0f) return null
|
||||||
return chipAt(col, (bandY / rowHeightPx).toInt(), laneCap)
|
return chipAt(col, (bandY / rowHeightPx).toInt(), MAX_EVENT_ROWS)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2470,7 +2346,6 @@ private fun monthChipDragModifier(
|
|||||||
controller: MonthDragController?,
|
controller: MonthDragController?,
|
||||||
band: Array<LayoutCoordinates?>,
|
band: Array<LayoutCoordinates?>,
|
||||||
rowHeightPx: Float,
|
rowHeightPx: Float,
|
||||||
laneCap: Int,
|
|
||||||
isRtl: Boolean,
|
isRtl: Boolean,
|
||||||
/** The row's formatted chip times by instance id, so the copy carries the
|
/** The row's formatted chip times by instance id, so the copy carries the
|
||||||
* one its source chip had rather than deriving another (#219). */
|
* one its source chip had rather than deriving another (#219). */
|
||||||
@@ -2489,7 +2364,7 @@ private fun monthChipDragModifier(
|
|||||||
val event = if (bandY < 0f || columnPx <= 0f) {
|
val event = if (bandY < 0f || columnPx <= 0f) {
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
week.chipAt(dayIndex, lane, laneCap)
|
week.chipAt(dayIndex, lane, MAX_EVENT_ROWS)
|
||||||
}
|
}
|
||||||
if (event == null || moveScope?.allows(event) != true) {
|
if (event == null || moveScope?.allows(event) != true) {
|
||||||
false
|
false
|
||||||
@@ -2547,7 +2422,8 @@ private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = weekNumber.toString(),
|
text = weekNumber.toString(),
|
||||||
style = weekNumberStyle(),
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2668,7 +2544,7 @@ private fun MonthBar(
|
|||||||
val titleOverflow = eventTitleOverflow()
|
val titleOverflow = eventTitleOverflow()
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
style = MaterialTheme.typography.labelSmall.trimmedLines(),
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = titleOverflow.overflow,
|
overflow = titleOverflow.overflow,
|
||||||
softWrap = titleOverflow.softWrap,
|
softWrap = titleOverflow.softWrap,
|
||||||
@@ -2689,7 +2565,6 @@ private fun OverflowDots(
|
|||||||
events: List<EventInstance>,
|
events: List<EventInstance>,
|
||||||
total: Int,
|
total: Int,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
rowHeight: Dp,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
@@ -2697,14 +2572,14 @@ private fun OverflowDots(
|
|||||||
val byColor = events.groupBy { it.color }
|
val byColor = events.groupBy { it.color }
|
||||||
val dots = byColor.keys.take(3)
|
val dots = byColor.keys.take(3)
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.height(rowHeight),
|
modifier = modifier.height(EVENT_ROW_HEIGHT),
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
dots.forEach { argb ->
|
dots.forEach { argb ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(OVERFLOW_DOT_SIZE)
|
.size(6.dp)
|
||||||
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
|
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
|
||||||
.background(eventAccent(argb, dark, soften), CircleShape),
|
.background(eventAccent(argb, dark, soften), CircleShape),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
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,19 +5,6 @@ enum class OnboardingStep {
|
|||||||
/** Required: nothing works without the calendar grant. */
|
/** Required: nothing works without the calendar grant. */
|
||||||
Permission,
|
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. */
|
/** Whether Calendula delivers reminder notifications itself. */
|
||||||
Reminders,
|
Reminders,
|
||||||
|
|
||||||
@@ -80,18 +67,6 @@ data class OnboardingPlan(
|
|||||||
* otherwise, so the flow can only ever get shorter — never sprout a step the
|
* otherwise, so the flow can only ever get shorter — never sprout a step the
|
||||||
* counter had not accounted for.
|
* 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:
|
* 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
|
* 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
|
* and went as the view is picked would move the counter under the user on the
|
||||||
@@ -100,9 +75,7 @@ data class OnboardingPlan(
|
|||||||
* [visibilityArmed] carries the one-time notice that the device is hiding some
|
* [visibilityArmed] carries the one-time notice that the device is hiding some
|
||||||
* calendars (#75) — the only optional step an *existing* install can be given,
|
* 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
|
* which is why it is not gated on `fresh`, and on its own it earns no closing
|
||||||
* screen. It stays a pure announcement: the all-hidden *state* is
|
* screen.
|
||||||
* [hiddenApplies]'s business, up at the front of the flow where something can
|
|
||||||
* still be done about it.
|
|
||||||
*/
|
*/
|
||||||
fun onboardingPlan(
|
fun onboardingPlan(
|
||||||
hasPermission: Boolean,
|
hasPermission: Boolean,
|
||||||
@@ -112,10 +85,6 @@ fun onboardingPlan(
|
|||||||
viewDone: Boolean,
|
viewDone: Boolean,
|
||||||
monthStyleDone: Boolean,
|
monthStyleDone: Boolean,
|
||||||
backupApplies: Boolean?,
|
backupApplies: Boolean?,
|
||||||
hiddenDone: Boolean = false,
|
|
||||||
hiddenApplies: Boolean? = false,
|
|
||||||
calendarsDone: Boolean = false,
|
|
||||||
calendarsApplies: Boolean? = null,
|
|
||||||
visibilityArmed: Boolean = false,
|
visibilityArmed: Boolean = false,
|
||||||
visibilityDone: Boolean = false,
|
visibilityDone: Boolean = false,
|
||||||
doneShown: Boolean = false,
|
doneShown: Boolean = false,
|
||||||
@@ -123,8 +92,6 @@ fun onboardingPlan(
|
|||||||
val fresh = wizardArmed || (!hasPermission && !remindersDone)
|
val fresh = wizardArmed || (!hasPermission && !remindersDone)
|
||||||
val steps = buildList {
|
val steps = buildList {
|
||||||
if (!hasPermission || fresh) add(OnboardingStep.Permission)
|
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 (!remindersDone || fresh) add(OnboardingStep.Reminders)
|
||||||
if (fresh && backupApplies != false) add(OnboardingStep.Backup)
|
if (fresh && backupApplies != false) add(OnboardingStep.Backup)
|
||||||
if (fresh) {
|
if (fresh) {
|
||||||
@@ -137,8 +104,6 @@ fun onboardingPlan(
|
|||||||
val current = steps.firstOrNull { step ->
|
val current = steps.firstOrNull { step ->
|
||||||
when (step) {
|
when (step) {
|
||||||
OnboardingStep.Permission -> !hasPermission
|
OnboardingStep.Permission -> !hasPermission
|
||||||
OnboardingStep.HiddenCalendars -> !hiddenDone
|
|
||||||
OnboardingStep.Calendars -> !calendarsDone
|
|
||||||
OnboardingStep.Reminders -> !remindersDone
|
OnboardingStep.Reminders -> !remindersDone
|
||||||
OnboardingStep.Backup -> !backupDone
|
OnboardingStep.Backup -> !backupDone
|
||||||
OnboardingStep.View -> !viewDone
|
OnboardingStep.View -> !viewDone
|
||||||
|
|||||||
@@ -28,9 +28,6 @@ fun OnboardingSteps(
|
|||||||
// Collected for every step, not just the two that draw a preview: starting
|
// 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.
|
// it when the picker composes would leave that step blank as it slides in.
|
||||||
val choice by viewModel.viewChoice.collectAsStateWithLifecycle()
|
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
|
// 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
|
// plan has since dropped — the backup step goes once the calendars say it
|
||||||
@@ -70,26 +67,6 @@ fun OnboardingSteps(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
progress = progress,
|
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(
|
OnboardingStep.Reminders -> ReminderStep(
|
||||||
onFinished = viewModel::finishReminders,
|
onFinished = viewModel::finishReminders,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|||||||
+10
-235
@@ -16,8 +16,6 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
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.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -26,7 +24,6 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -77,27 +74,6 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
initialValue = null,
|
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
|
* Whether the backup step applies: something of yours is worth exporting and
|
||||||
* nothing you write to is synced anywhere. Null while the calendar list
|
* nothing you write to is synced anywhere. Null while the calendar list
|
||||||
@@ -105,90 +81,17 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
* an empty list is also how a null cursor arrives, and guessing false there
|
* an empty list is also how a null cursor arrives, and guessing false there
|
||||||
* would drop the step for the whole session.
|
* would drop the step for the whole session.
|
||||||
*/
|
*/
|
||||||
private val backupApplies: Flow<Boolean?> = calendars.map { list ->
|
private val backupApplies: Flow<Boolean?> = hasPermission.flatMapLatest { granted ->
|
||||||
if (list.isNullOrEmpty()) null else list.backupApplies()
|
if (granted != true) {
|
||||||
|
flowOf(null)
|
||||||
|
} else {
|
||||||
|
repository.calendars()
|
||||||
|
.map { calendars -> if (calendars.isEmpty()) null else calendars.backupApplies() }
|
||||||
|
.catch { emit(null) }
|
||||||
|
.flowOn(io)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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(
|
private val flags: Flow<OnboardingFlags> = combine(
|
||||||
prefs.reminderOnboardingDone,
|
prefs.reminderOnboardingDone,
|
||||||
prefs.onboardingWizardArmed,
|
prefs.onboardingWizardArmed,
|
||||||
@@ -207,13 +110,7 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val plan: StateFlow<OnboardingPlan?> =
|
val plan: StateFlow<OnboardingPlan?> =
|
||||||
combine(
|
combine(hasPermission, flags, closingFlags, backupApplies) { granted, stored, closing, backup ->
|
||||||
hasPermission,
|
|
||||||
flags,
|
|
||||||
closingFlags,
|
|
||||||
backupApplies,
|
|
||||||
calendarsFlags,
|
|
||||||
) { granted, stored, closing, backup, calendars ->
|
|
||||||
granted?.let {
|
granted?.let {
|
||||||
onboardingPlan(
|
onboardingPlan(
|
||||||
hasPermission = it,
|
hasPermission = it,
|
||||||
@@ -223,10 +120,6 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
viewDone = stored.viewDone,
|
viewDone = stored.viewDone,
|
||||||
monthStyleDone = stored.monthStyleDone,
|
monthStyleDone = stored.monthStyleDone,
|
||||||
backupApplies = backup,
|
backupApplies = backup,
|
||||||
hiddenDone = calendars.hiddenDone,
|
|
||||||
hiddenApplies = calendars.hiddenApplies,
|
|
||||||
calendarsDone = calendars.done,
|
|
||||||
calendarsApplies = calendars.applies,
|
|
||||||
visibilityArmed = closing.visibilityArmed,
|
visibilityArmed = closing.visibilityArmed,
|
||||||
visibilityDone = closing.visibilityDone,
|
visibilityDone = closing.visibilityDone,
|
||||||
doneShown = closing.doneShown,
|
doneShown = closing.doneShown,
|
||||||
@@ -293,8 +186,6 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
fun goBack() {
|
fun goBack() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when (plan.value?.previous) {
|
when (plan.value?.previous) {
|
||||||
OnboardingStep.HiddenCalendars -> prefs.setOnboardingHiddenCalendarsDone(false)
|
|
||||||
OnboardingStep.Calendars -> prefs.setOnboardingCalendarsDone(false)
|
|
||||||
OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false)
|
OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false)
|
||||||
OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false)
|
OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false)
|
||||||
OnboardingStep.View -> prefs.setOnboardingViewDone(false)
|
OnboardingStep.View -> prefs.setOnboardingViewDone(false)
|
||||||
@@ -311,53 +202,6 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setOnboardingBackupDone() }
|
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
|
* Turn automatic backup on, writing to the folder the user just picked
|
||||||
* (taking a durable write grant so background runs can keep writing), and
|
* (taking a durable write grant so background runs can keep writing), and
|
||||||
@@ -402,52 +246,6 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setOnboardingMonthStyleDone() }
|
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). */
|
/** Acknowledge the calendar-visibility notice (#75). */
|
||||||
fun finishVisibility() {
|
fun finishVisibility() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -497,20 +295,6 @@ private data class OnboardingFlags(
|
|||||||
val monthStyleDone: Boolean,
|
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. */
|
/** The tail of the flow: the visibility notice and the closing screen. */
|
||||||
private data class ClosingFlags(
|
private data class ClosingFlags(
|
||||||
val visibilityArmed: Boolean,
|
val visibilityArmed: Boolean,
|
||||||
@@ -525,15 +309,6 @@ data class ViewChoice(
|
|||||||
val weekStart: WeekStartPref,
|
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
|
* 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.
|
* calendar worth exporting, and no writable synced calendar to carry them.
|
||||||
|
|||||||
@@ -509,9 +509,6 @@ private fun SearchResultRow(
|
|||||||
onLongClick: (() -> Unit)? = null,
|
onLongClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val event = hit.event
|
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 dark = isSystemInDarkTheme()
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
// On a picked row the headline is already recoloured for the secondary
|
// On a picked row the headline is already recoloured for the secondary
|
||||||
@@ -524,7 +521,7 @@ private fun SearchResultRow(
|
|||||||
// Faded like a past event anywhere else in the app — search reaches back
|
// Faded like a past event anywhere else in the app — search reaches back
|
||||||
// through the whole history.
|
// through the whole history.
|
||||||
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
|
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
|
||||||
title = declinedTitle(marked(title, hit.titleSpans, highlight), event.isDeclined),
|
title = declinedTitle(marked(event.title, hit.titleSpans, highlight), event.isDeclined),
|
||||||
summary = searchSummary(hit, highlight),
|
summary = searchSummary(hit, highlight),
|
||||||
position = position,
|
position = position,
|
||||||
minHeight = 64.dp,
|
minHeight = 64.dp,
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ internal fun EventFormScreen(
|
|||||||
durationCalendars.forEachIndexed { index, calendar ->
|
durationCalendars.forEachIndexed { index, calendar ->
|
||||||
val override = state.perCalendarEventDuration[calendar.id]
|
val override = state.perCalendarEventDuration[calendar.id]
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
title = calendar.displayName,
|
||||||
summary = override?.let { durationLabel(it) }
|
summary = override?.let { durationLabel(it) }
|
||||||
?: stringResource(
|
?: stringResource(
|
||||||
R.string.settings_calendar_duration_inherits,
|
R.string.settings_calendar_duration_inherits,
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ internal fun NotificationsScreen(
|
|||||||
// own section — link there instead.
|
// own section — link there instead.
|
||||||
if (calendar.id in state.managedCalendarIds) {
|
if (calendar.id in state.managedCalendarIds) {
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
title = calendar.displayName,
|
||||||
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
||||||
position = Position.Alone,
|
position = Position.Alone,
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
@@ -198,7 +198,7 @@ internal fun NotificationsScreen(
|
|||||||
}
|
}
|
||||||
val expanded = calendar.id in expandedCalendars
|
val expanded = calendar.id in expandedCalendars
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
title = calendar.displayName,
|
||||||
position = if (expanded) Position.Top else Position.Alone,
|
position = if (expanded) Position.Top else Position.Alone,
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
trailing = {
|
trailing = {
|
||||||
|
|||||||
@@ -1,24 +1,10 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.theme
|
package de.jeanlucmakiola.calendula.ui.theme
|
||||||
|
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tracking the two label roles the calendar grids are set in. Material gives
|
* Default Material 3 Expressive typography. Custom font + tuned scale will
|
||||||
* both 0.5sp, tuned for isolated UI labels with room around them; a month chip
|
* land in a later UI-design iteration; the defaults are intentional for V1
|
||||||
* is a text box some 37dp wide, where 0.5sp on an 11sp glyph spends most of a
|
* scaffolding to keep the foundation lean.
|
||||||
* character on spacing alone. 0.1sp is what Material itself sets labelLarge to,
|
|
||||||
* so the label family stays coherent (#190).
|
|
||||||
*/
|
*/
|
||||||
private val LabelTracking = 0.1.sp
|
val CalendulaTypography = Typography()
|
||||||
|
|
||||||
/**
|
|
||||||
* 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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -91,11 +91,11 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
|
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_PADDING
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET
|
||||||
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
|
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
|
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
|
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.animatedBlockPlacement
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
@@ -118,7 +118,6 @@ import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
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.NowLine
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
@@ -156,8 +155,7 @@ import kotlin.time.Clock
|
|||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/** One lane of the all-day strip, sized to a trimmed bar line (#190). */
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_ROW_HEIGHT = 20.dp
|
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
/** Gap between day columns; part of the column pitch a drag maps positions through. */
|
/** Gap between day columns; part of the column pitch a drag maps positions through. */
|
||||||
private val COLUMN_GAP = 2.dp
|
private val COLUMN_GAP = 2.dp
|
||||||
@@ -573,7 +571,7 @@ private fun WeekDayHeader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Calendar-week badge shown in the header gutter, deliberately set apart with a
|
/** Calendar-week badge shown in the header gutter, deliberately set apart with a
|
||||||
* filled box and bold number — at the month grid's size, so the two agree (#213). */
|
* filled box and bold number. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
|
private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
|
||||||
val label = stringResource(R.string.week_number_label)
|
val label = stringResource(R.string.week_number_label)
|
||||||
@@ -585,9 +583,9 @@ private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = weekNumber.toString(),
|
text = weekNumber.toString(),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp),
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -666,7 +664,7 @@ private fun AllDayBar(
|
|||||||
val titleOverflow = eventTitleOverflow()
|
val titleOverflow = eventTitleOverflow()
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelSmall.trimmedLines(),
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = titleOverflow.overflow,
|
overflow = titleOverflow.overflow,
|
||||||
softWrap = titleOverflow.softWrap,
|
softWrap = titleOverflow.softWrap,
|
||||||
@@ -911,7 +909,13 @@ private fun EventBlock(
|
|||||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||||
minToHm(block.endMin, use24Hour, locale)
|
minToHm(block.endMin, use24Hour, locale)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val metrics = rememberBlockTextMetrics(height)
|
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
|
||||||
// Only full-width (non-overlapping) blocks that are tall enough show the
|
// 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
|
// 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
|
// fill the whole block, mirroring Google Calendar — and a block that cannot
|
||||||
@@ -919,19 +923,19 @@ private fun EventBlock(
|
|||||||
// its own: a duration threshold would keep hiding the time on a half-hour
|
// 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.
|
// block the user has pinched open to three times the room it needs.
|
||||||
val showTime = block.laneCount == 1 &&
|
val showTime = block.laneCount == 1 &&
|
||||||
metrics.available >= metrics.titleLine + metrics.timeLine
|
available >= titleLineHeight + timeLineHeight
|
||||||
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
||||||
// A short block drops the title rather than serving a horizontally sliced
|
// 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
|
// 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
|
// 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.
|
// the semantics description carries the full title either way.
|
||||||
val showTitle = metrics.fitsTitle
|
val showTitle = available >= titleLineHeight
|
||||||
// The title is served first, out of everything the block has left once the
|
// 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,
|
// 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
|
// 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.
|
// syllable. Below that the extra lines just stack fragments of the word.
|
||||||
val contentHeight = metrics.available - if (showTime) metrics.timeLine else 0.dp
|
val contentHeight = available - if (showTime) timeLineHeight else 0.dp
|
||||||
val titleBudget = metrics.titleBudget(contentHeight).coerceAtLeast(1)
|
val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||||
val paint = eventPaint(block.event, dark)
|
val paint = eventPaint(block.event, dark)
|
||||||
// Every line the height affords, however narrow the lane: two events side by
|
// 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
|
// side leave columns well under a word wide, and cutting the title to one
|
||||||
@@ -944,8 +948,8 @@ private fun EventBlock(
|
|||||||
textWidth = textWidth,
|
textWidth = textWidth,
|
||||||
max = titleBudget,
|
max = titleBudget,
|
||||||
)
|
)
|
||||||
val spare = metrics.available - metrics.titleHeight(titleMaxLines) -
|
val spare = available - titleLineHeight * titleMaxLines -
|
||||||
if (showTime) metrics.timeLine else 0.dp
|
if (showTime) timeLineHeight else 0.dp
|
||||||
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
|
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
|
||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||||
@@ -987,7 +991,7 @@ private fun EventBlock(
|
|||||||
// After clickable, so it is the inner node and wins the main pass;
|
// After clickable, so it is the inner node and wins the main pass;
|
||||||
// the tap still works, since a drag consumes the up.
|
// the tap still works, since a drag consumes the up.
|
||||||
.then(dragModifier)
|
.then(dragModifier)
|
||||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
|
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET)
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = "$title, $timeLabel"
|
contentDescription = "$title, $timeLabel"
|
||||||
if (moveAction != null) customActions = listOf(moveAction)
|
if (moveAction != null) customActions = listOf(moveAction)
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ internal fun layoutAllDay(
|
|||||||
// in non-decreasing start order, which the declined-last rule above breaks:
|
// 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
|
// 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
|
// nowhere near, and each wasted lane costs the all-day strip a whole row and
|
||||||
// pushes a bar closer to the month grid's lane cap. Seven columns
|
// pushes a bar closer to the month grid's MAX_EVENT_ROWS cap. Seven columns
|
||||||
// and a handful of bars, so the scan is cheaper than the sort above it.
|
// and a handful of bars, so the scan is cheaper than the sort above it.
|
||||||
val laneCols = ArrayList<MutableList<IntRange>>()
|
val laneCols = ArrayList<MutableList<IntRange>>()
|
||||||
return raw.map { r ->
|
return raw.map { r ->
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
<string name="event_edit_timezone_search">Vyhledat časové pásmo</string>
|
<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_recent">Nedávné</string>
|
||||||
<string name="event_edit_timezone_all">Všechna časová pásma</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_timezone_local_time">%1$s vašeho času</string>
|
||||||
<string name="event_edit_color">Barva</string>
|
<string name="event_edit_color">Barva</string>
|
||||||
<string name="event_edit_color_default">Barva kalendáře</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">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_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_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_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">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>
|
<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_months">Každý %1$d. měsíc</string>
|
||||||
<string name="recurrence_every_n_years">Každý %1$d. rok</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_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="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_detail_not_found">Tato událost již neexistuje.</string>
|
||||||
<string name="event_attendee_accepted">Přijato</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_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_organizer">Pořadatel</string>
|
||||||
<string name="event_attendee_optional">Volitelné</string>
|
<string name="event_attendee_optional">Volitelné</string>
|
||||||
<string name="event_attendee_resource">Prostředky</string>
|
<string name="event_attendee_resource">Zdroj</string>
|
||||||
<string name="event_detail_self_response">Vaše odpověď: %1$s</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_at_time">V době konání události</string>
|
||||||
<string name="reminder_default">Výchozí připomínka</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_back">Zpět</string>
|
||||||
<string name="search_clear">Vymazat</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_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">
|
<plurals name="search_selected_count">
|
||||||
<item quantity="one">%d označený</item>
|
<item quantity="one">%d označený</item>
|
||||||
<item quantity="few">%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_medium">Střední</string>
|
||||||
<string name="settings_widget_size_large">Velký</string>
|
<string name="settings_widget_size_large">Velký</string>
|
||||||
<string name="settings_widget_size_extra_large">Největší</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">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="settings_agenda_show_today_hint">Ponechat dnešní den na prvním místě agendy a jejího widgetu, třebaže již nezbývá žádná nadcházející událost.</string>
|
||||||
<string name="state_failure_all_hidden">Všechny vaše kalendáře jsou vypnuté.</string>
|
<string name="state_failure_all_hidden">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="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>
|
<string name="agenda_range_showing_label">Zobrazit všechny nadcházející události pro</string>
|
||||||
<plurals name="agenda_range_days">
|
<plurals name="agenda_range_days">
|
||||||
<item quantity="one">%d den</item>
|
<item quantity="one">%dden</item>
|
||||||
<item quantity="few">%d dny</item>
|
<item quantity="few">%ddny</item>
|
||||||
<item quantity="many">%d dnů</item>
|
<item quantity="many">%ddnů</item>
|
||||||
<item quantity="other">%d dnů</item>
|
<item quantity="other">%ddnů</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="settings_section_views">Zobrazení</string>
|
<string name="settings_section_views">Pohledy</string>
|
||||||
<string name="settings_month_header">Měsíční 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="settings_month_view_style">Styl měsíčního zobrazení</string>
|
||||||
<string name="month_style_paged">Stránky</string>
|
<string name="month_style_paged">Stránky</string>
|
||||||
@@ -485,212 +485,4 @@
|
|||||||
<string name="settings_reliable_delivery_exempt">Výloučení z optimalizace baterie – připomínky chodí bez prodlení.</string>
|
<string name="settings_reliable_delivery_exempt">Výloučení z optimalizace baterie – připomínky chodí bez prodlení.</string>
|
||||||
<string name="settings_notifications_subtitle">Připomínky a doručení</string>
|
<string name="settings_notifications_subtitle">Připomínky a doručení</string>
|
||||||
<string name="settings_special_dates_reminders">Připomínky</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>
|
</resources>
|
||||||
|
|||||||
@@ -263,44 +263,4 @@
|
|||||||
<string name="onboarding_done_button">Otvoriť môj kalendár</string>
|
<string name="onboarding_done_button">Otvoriť môj kalendár</string>
|
||||||
<string name="reminder_channel_name">Pripomienky udalostí</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="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>
|
</resources>
|
||||||
|
|||||||
@@ -11,20 +11,12 @@
|
|||||||
<string name="state_retry">Retry</string>
|
<string name="state_retry">Retry</string>
|
||||||
<string name="state_failure_unknown">Something went wrong.</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">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_permission_action">Grant access</string>
|
||||||
<string name="state_failure_no_calendars">No calendars configured.</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_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">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_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">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) -->
|
<!-- Long-press a field to copy it (#195) -->
|
||||||
<string name="field_copy_action">Copy</string>
|
<string name="field_copy_action">Copy</string>
|
||||||
@@ -290,9 +282,6 @@
|
|||||||
<!-- Shared event strings -->
|
<!-- Shared event strings -->
|
||||||
<string name="event_untitled">(No title)</string>
|
<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) -->
|
<!-- First-launch wizard (#163) -->
|
||||||
<string name="onboarding_step_counter">Step %1$d of %2$d</string>
|
<string name="onboarding_step_counter">Step %1$d of %2$d</string>
|
||||||
<string name="onboarding_backup_title">Your events live only here</string>
|
<string name="onboarding_backup_title">Your events live only here</string>
|
||||||
@@ -301,26 +290,6 @@
|
|||||||
<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_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_title">Once a day, by itself</string>
|
||||||
<string name="onboarding_backup_benefit_daily_body">Calendula exports your local calendars in the background. Change how often in Settings.</string>
|
<string name="onboarding_backup_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_enable_button">Choose folder and back up</string>
|
||||||
<string name="onboarding_backup_skip_button">Not now</string>
|
<string name="onboarding_backup_skip_button">Not now</string>
|
||||||
<string name="onboarding_view_title">What should open first?</string>
|
<string name="onboarding_view_title">What should open first?</string>
|
||||||
@@ -696,20 +665,7 @@
|
|||||||
<string name="calendars_edit_title">Edit calendar</string>
|
<string name="calendars_edit_title">Edit calendar</string>
|
||||||
<string name="calendars_name_label">Name</string>
|
<string name="calendars_name_label">Name</string>
|
||||||
<string name="calendars_color_label">Color</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="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_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_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>
|
<string name="calendars_write_error">Couldn\'t save the change.</string>
|
||||||
@@ -723,7 +679,6 @@
|
|||||||
<string name="calendars_restore_header">Restore</string>
|
<string name="calendars_restore_header">Restore</string>
|
||||||
<string name="calendars_restore_action">Restore from .ics file</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_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">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_hint">Periodically export your local calendars to a folder as an .ics file.</string>
|
||||||
<string name="calendars_auto_backup_folder">Backup folder</string>
|
<string name="calendars_auto_backup_folder">Backup folder</string>
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ class CalendarMapperTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `null displayName is left blank for the UI to fill in`() {
|
fun `null displayName falls back to placeholder`() {
|
||||||
val src = reader(displayName = null).toCalendarSource()
|
val src = reader(displayName = null).toCalendarSource()
|
||||||
assertThat(src.displayName).isEmpty()
|
assertThat(src.displayName).isEqualTo(Fallbacks.UNNAMED_CALENDAR)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -61,15 +61,15 @@ class InstanceMapperTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `null title is left blank for the UI to fill in`() {
|
fun `null title falls back to placeholder`() {
|
||||||
val inst = reader(title = null).toEventInstance()
|
val inst = reader(title = null).toEventInstance()
|
||||||
assertThat(inst!!.title).isEmpty()
|
assertThat(inst!!.title).isEqualTo(Fallbacks.UNTITLED_EVENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `empty title is left blank for the UI to fill in`() {
|
fun `empty title falls back to placeholder`() {
|
||||||
val inst = reader(title = "").toEventInstance()
|
val inst = reader(title = "").toEventInstance()
|
||||||
assertThat(inst!!.title).isEmpty()
|
assertThat(inst!!.title).isEqualTo(Fallbacks.UNTITLED_EVENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -242,8 +242,8 @@ class EventSearchTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an untitled event's blank title is not something the query can match`() {
|
fun `an untitled event's placeholder is not something the query can match`() {
|
||||||
val untitled = candidate("", description = "Titel folgt noch").copy(title = null)
|
val untitled = candidate("(Ohne Titel)", description = "Titel folgt noch").copy(title = null)
|
||||||
|
|
||||||
val results = search(untitled, query = "titel")
|
val results = search(untitled, query = "titel")
|
||||||
|
|
||||||
|
|||||||
@@ -36,13 +36,12 @@ class ModelsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `FailureReason enum has all seven variants`() {
|
fun `FailureReason enum has all six variants`() {
|
||||||
assertThat(FailureReason.values().toSet()).isEqualTo(
|
assertThat(FailureReason.values().toSet()).isEqualTo(
|
||||||
setOf(
|
setOf(
|
||||||
FailureReason.PermissionRevoked,
|
FailureReason.PermissionRevoked,
|
||||||
FailureReason.NoCalendarsConfigured,
|
FailureReason.NoCalendarsConfigured,
|
||||||
FailureReason.AllCalendarsHidden,
|
FailureReason.AllCalendarsHidden,
|
||||||
FailureReason.NoImportTarget,
|
|
||||||
FailureReason.ProviderUnavailable,
|
FailureReason.ProviderUnavailable,
|
||||||
FailureReason.EventNotFound,
|
FailureReason.EventNotFound,
|
||||||
FailureReason.Unknown,
|
FailureReason.Unknown,
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
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
@@ -1,66 +0,0 @@
|
|||||||
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 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.ui.unit.Density
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
@@ -76,16 +75,17 @@ class TimelineZoomTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a pinch held against either bound stays put`() {
|
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.
|
||||||
val fillPx = 62.083f
|
val fillPx = 62.083f
|
||||||
val maxPx = 616.5f
|
val maxPx = 616.5f
|
||||||
|
|
||||||
val floor = pinchedHourHeightPx(target = 1f, fillPx, maxPx)
|
val floor = pinchedHourHeightPx(target = 1f, fillPx, maxPx)
|
||||||
val ceiling = pinchedHourHeightPx(target = 9_000f, fillPx, maxPx)
|
val ceiling = pinchedHourHeightPx(target = 9_000f, fillPx, maxPx)
|
||||||
|
|
||||||
// The ceiling is pulled onto the pixel grid so it stays landable; the
|
assertThat(floor).isEqualTo(63f)
|
||||||
// fill floor is landable as it is, being the clamp result itself.
|
|
||||||
assertThat(floor).isEqualTo(fillPx)
|
|
||||||
assertThat(ceiling).isEqualTo(616f)
|
assertThat(ceiling).isEqualTo(616f)
|
||||||
// Landing there and being pushed further must not move them again.
|
// Landing there and being pushed further must not move them again.
|
||||||
assertThat(pinchedHourHeightPx(floor * 0.9f, fillPx, maxPx)).isEqualTo(floor)
|
assertThat(pinchedHourHeightPx(floor * 0.9f, fillPx, maxPx)).isEqualTo(floor)
|
||||||
@@ -100,35 +100,6 @@ class TimelineZoomTest {
|
|||||||
assertThat(floor * 24).isAtLeast(viewport)
|
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
|
@Test
|
||||||
fun `a settled pinch is what gets persisted`() {
|
fun `a settled pinch is what gets persisted`() {
|
||||||
var persisted: TimelineScale? = null
|
var persisted: TimelineScale? = null
|
||||||
|
|||||||
@@ -56,13 +56,7 @@ class ChipAtCellYTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
private fun MonthWeek.chipAt(col: Int, cellY: Float) =
|
private fun MonthWeek.chipAt(col: Int, cellY: Float) =
|
||||||
chipAtCellY(
|
chipAtCellY(col = col, cellY = cellY, bandTopInCell = bandTop, rowHeightPx = laneHeight)
|
||||||
col = col,
|
|
||||||
cellY = cellY,
|
|
||||||
bandTopInCell = bandTop,
|
|
||||||
rowHeightPx = laneHeight,
|
|
||||||
laneCap = SPLIT_DOT_LANES,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a tap on a lane resolves to the chip seated there`() {
|
fun `a tap on a lane resolves to the chip seated there`() {
|
||||||
@@ -104,13 +98,13 @@ class ChipAtCellYTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a tap on the overflow row opens the day rather than a hidden event`() {
|
fun `a tap on the overflow row opens the day rather than a hidden event`() {
|
||||||
val events = (1..SPLIT_DOT_LANES + 2).map {
|
val events = (1..MAX_EVENT_ROWS + 2).map {
|
||||||
timed(LocalDate(2026, 7, 7), hour = it, id = it.toLong())
|
timed(LocalDate(2026, 7, 7), hour = it, id = it.toLong())
|
||||||
}
|
}
|
||||||
val week = rowOfJuly6(events)
|
val week = rowOfJuly6(events)
|
||||||
|
|
||||||
// The dots sit one lane below the last one the row draws.
|
// The dots sit one lane below the last one the row draws.
|
||||||
val overflowY = bandTop + laneHeight * SPLIT_DOT_LANES + 2f
|
val overflowY = bandTop + laneHeight * MAX_EVENT_ROWS + 2f
|
||||||
assertThat(week.chipAt(col = 1, cellY = overflowY)).isNull()
|
assertThat(week.chipAt(col = 1, cellY = overflowY)).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,22 +113,10 @@ class ChipAtCellYTest {
|
|||||||
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)))
|
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)))
|
||||||
|
|
||||||
assertThat(
|
assertThat(
|
||||||
week.chipAtCellY(
|
week.chipAtCellY(col = 1, cellY = 45f, bandTopInCell = null, rowHeightPx = laneHeight),
|
||||||
col = 1,
|
|
||||||
cellY = 45f,
|
|
||||||
bandTopInCell = null,
|
|
||||||
rowHeightPx = laneHeight,
|
|
||||||
laneCap = SPLIT_DOT_LANES,
|
|
||||||
),
|
|
||||||
).isNull()
|
).isNull()
|
||||||
assertThat(
|
assertThat(
|
||||||
week.chipAtCellY(
|
week.chipAtCellY(col = 1, cellY = 45f, bandTopInCell = bandTop, rowHeightPx = 0f),
|
||||||
col = 1,
|
|
||||||
cellY = 45f,
|
|
||||||
bandTopInCell = bandTop,
|
|
||||||
rowHeightPx = 0f,
|
|
||||||
laneCap = SPLIT_DOT_LANES,
|
|
||||||
),
|
|
||||||
).isNull()
|
).isNull()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
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,10 +18,6 @@ class OnboardingPlanTest {
|
|||||||
viewDone: Boolean = false,
|
viewDone: Boolean = false,
|
||||||
monthStyleDone: Boolean = false,
|
monthStyleDone: Boolean = false,
|
||||||
backupApplies: Boolean? = null,
|
backupApplies: Boolean? = null,
|
||||||
hiddenDone: Boolean = false,
|
|
||||||
hiddenApplies: Boolean? = false,
|
|
||||||
calendarsDone: Boolean = false,
|
|
||||||
calendarsApplies: Boolean? = false,
|
|
||||||
visibilityArmed: Boolean = false,
|
visibilityArmed: Boolean = false,
|
||||||
visibilityDone: Boolean = false,
|
visibilityDone: Boolean = false,
|
||||||
doneShown: Boolean = false,
|
doneShown: Boolean = false,
|
||||||
@@ -33,10 +29,6 @@ class OnboardingPlanTest {
|
|||||||
viewDone = viewDone,
|
viewDone = viewDone,
|
||||||
monthStyleDone = monthStyleDone,
|
monthStyleDone = monthStyleDone,
|
||||||
backupApplies = backupApplies,
|
backupApplies = backupApplies,
|
||||||
hiddenDone = hiddenDone,
|
|
||||||
hiddenApplies = hiddenApplies,
|
|
||||||
calendarsDone = calendarsDone,
|
|
||||||
calendarsApplies = calendarsApplies,
|
|
||||||
visibilityArmed = visibilityArmed,
|
visibilityArmed = visibilityArmed,
|
||||||
visibilityDone = visibilityDone,
|
visibilityDone = visibilityDone,
|
||||||
doneShown = doneShown,
|
doneShown = doneShown,
|
||||||
@@ -251,144 +243,4 @@ class OnboardingPlanTest {
|
|||||||
)
|
)
|
||||||
assertThat(read.current).isNull()
|
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,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.theme
|
package de.jeanlucmakiola.calendula.ui.theme
|
||||||
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
@@ -49,19 +48,6 @@ class FontsTest {
|
|||||||
assertThat(typography.labelSmall.fontFamily).isEqualTo(plain)
|
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
|
@Test
|
||||||
fun `a null role keeps that role's default family while the other is applied`() {
|
fun `a null role keeps that role's default family while the other is applied`() {
|
||||||
val plain = BundledFont.Lora.family
|
val plain = BundledFont.Lora.family
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
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,19 +113,6 @@ 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 the only optional step an *already onboarded* install can be given, which
|
||||||
is why it alone is not gated on the wizard being armed.
|
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
|
## Recurring writes
|
||||||
|
|
||||||
The provider's invariants drive the design (learned the hard way, verified
|
The provider's invariants drive the design (learned the hard way, verified
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: Privacy Policy — Calendula
|
title: Privacy Policy — Calendula
|
||||||
description: What Calendula does with your data. It collects nothing, sends nothing, and has no internet permission at all.
|
description: What Calendula does with your data. It collects nothing, sends nothing, and has no internet permission at all.
|
||||||
updated: 2026-09-15
|
updated: 2026-07-28
|
||||||
---
|
---
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -33,7 +33,7 @@ Calendula collects nothing, sends nothing, and has no user accounts. It has **no
|
|||||||
IT-Dienstleister | Jean-Luc Makiola
|
IT-Dienstleister | Jean-Luc Makiola
|
||||||
Mahlerstraße 10
|
Mahlerstraße 10
|
||||||
14772 Brandenburg an der Havel
|
14772 Brandenburg an der Havel
|
||||||
Email: [support@jeanlucmakiola.de](mailto:support@jeanlucmakiola.de)
|
Email: [business@jeanlucmakiola.de](mailto:business@jeanlucmakiola.de)
|
||||||
|
|
||||||
## 2. No data collection
|
## 2. No data collection
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -4,7 +4,7 @@ Name: Calendula
|
|||||||
Summary: A modern Material 3 Expressive calendar for Android.
|
Summary: A modern Material 3 Expressive calendar for Android.
|
||||||
|
|
||||||
Categories:
|
Categories:
|
||||||
- Calendar & Agenda
|
- Time
|
||||||
|
|
||||||
SourceCode: https://codeberg.org/jlmakiola/calendula
|
SourceCode: https://codeberg.org/jlmakiola/calendula
|
||||||
IssueTracker: https://codeberg.org/jlmakiola/calendula/issues
|
IssueTracker: https://codeberg.org/jlmakiola/calendula/issues
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ kotlin = "2.3.21"
|
|||||||
ksp = "2.3.11"
|
ksp = "2.3.11"
|
||||||
hilt = "2.60.1"
|
hilt = "2.60.1"
|
||||||
coreKtx = "1.19.0"
|
coreKtx = "1.19.0"
|
||||||
appcompat = "1.7.1"
|
appcompat = "1.8.0"
|
||||||
lifecycleRuntime = "2.11.0"
|
lifecycleRuntime = "2.11.0"
|
||||||
activityCompose = "1.13.0"
|
activityCompose = "1.13.0"
|
||||||
composeBom = "2026.06.01"
|
composeBom = "2026.06.01"
|
||||||
|
|||||||
Reference in New Issue
Block a user