Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e44425393 | ||
|
|
50ac6d758b | ||
|
|
4f74f36bf5 | ||
|
|
a75e1a8c2f | ||
|
|
4c560a9aa4 | ||
|
|
2dc22dc480 | ||
|
|
714f45d869 | ||
|
|
2670269776 | ||
|
|
bdcf2b3823 |
@@ -5,55 +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
|
## [2.20.4] — 2026-09-20
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -1763,14 +1714,3 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#309]: https://codeberg.org/jlmakiola/calendula/issues/309
|
[#309]: https://codeberg.org/jlmakiola/calendula/issues/309
|
||||||
[#305]: https://codeberg.org/jlmakiola/calendula/issues/305
|
[#305]: https://codeberg.org/jlmakiola/calendula/issues/305
|
||||||
[#294]: https://codeberg.org/jlmakiola/calendula/issues/294
|
[#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 = 22004
|
||||||
versionName = "2.21.0"
|
versionName = "2.20.4"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
+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 =
|
||||||
|
|||||||
@@ -312,7 +312,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) },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) },
|
||||||
|
|||||||
@@ -527,8 +527,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) },
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -290,9 +290,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 +298,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 +673,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>
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
|
||||||
Reference in New Issue
Block a user