Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f05a53ed2 | ||
|
|
9ca10fc6ee | ||
|
|
994fa010b8 |
@@ -5,6 +5,55 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [2.21.0] — 2026-09-23
|
||||
|
||||
### Added
|
||||
- **Setup makes sure you have a calendar to write to.** A fresh install could
|
||||
finish onboarding and land on an empty grid with nothing saying why — either
|
||||
because the device had no calendar at all, or because every calendar it had
|
||||
was switched off. Setup now covers both: it offers to create a calendar on
|
||||
this device or to add an account that syncs one, and it lists the calendars
|
||||
that exist but are switched off so you can turn them back on ([#287]).
|
||||
|
||||
### Changed
|
||||
- **The month grid gives its days more room.** Week numbers are set smaller and
|
||||
their gutter is measured from the widest number it has to seat instead of
|
||||
holding a fixed 40dp; event chips spend less of their width on padding; and
|
||||
the labels in both are tracked more tightly, which is worth roughly a
|
||||
character per chip ([#189], [#190], [#212], [#213]).
|
||||
- **A month day shows as many events as it has height for.** The limit was a
|
||||
flat three at every size, so a tall week row threw away space it could have
|
||||
filled, while a cramped one drew a third chip its band could not hold and put
|
||||
the "more" marker below the clip, where nothing showed the day held more at
|
||||
all. It is measured per week row now, and a tablet or a landscape phone seats
|
||||
more of them.
|
||||
- **Split view no longer pads a short month out to six rows.** A five-row month
|
||||
was handing the day pane a blank row and its gap — a sixth of the grid. The
|
||||
grid now draws only the rows its month spans and the pane grows into the rest
|
||||
([#162]).
|
||||
|
||||
### Fixed
|
||||
- **Short events show their title in week and day view.** A block needed 20dp
|
||||
of height before it drew any title at all, which at fit-the-whole-day put the
|
||||
cutoff around the half hour. The padding now tapers as the block shrinks, so
|
||||
a short event keeps its name ([#289]).
|
||||
- **A fully zoomed-out timeline sits still.** Rounding its height up left it as
|
||||
much as a pixel per hour taller than the viewport those hours are meant to
|
||||
fill — some 24px of leftover scroll, enough to bounce off Android's
|
||||
overscroll stretch ([#290]).
|
||||
- **Backup & restore says what is going on instead of coming up blank.** It
|
||||
built its rows straight from the calendar list, so loading, a provider error,
|
||||
and a device with nothing to export all rendered as an empty page with no
|
||||
buttons on it ([#304]).
|
||||
- **An event with no title reads in your language.** It was labelled "(Ohne
|
||||
Titel)" on every screen but the event's own, whatever language the phone was
|
||||
set to: the German text was chosen before the translated one could be
|
||||
([#321]).
|
||||
- **A calendar the system gives no name reads in your language.** The same
|
||||
cause, in the calendar lists and pickers — and because the calendar editor
|
||||
starts from that name, saving such a calendar wrote the German text into it
|
||||
for real ([#329]).
|
||||
|
||||
## [2.20.4] — 2026-09-20
|
||||
|
||||
### Fixed
|
||||
@@ -1714,3 +1763,14 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#309]: https://codeberg.org/jlmakiola/calendula/issues/309
|
||||
[#305]: https://codeberg.org/jlmakiola/calendula/issues/305
|
||||
[#294]: https://codeberg.org/jlmakiola/calendula/issues/294
|
||||
[#287]: https://codeberg.org/jlmakiola/calendula/issues/287
|
||||
[#189]: https://codeberg.org/jlmakiola/calendula/issues/189
|
||||
[#190]: https://codeberg.org/jlmakiola/calendula/issues/190
|
||||
[#212]: https://codeberg.org/jlmakiola/calendula/issues/212
|
||||
[#213]: https://codeberg.org/jlmakiola/calendula/issues/213
|
||||
[#162]: https://codeberg.org/jlmakiola/calendula/issues/162
|
||||
[#289]: https://codeberg.org/jlmakiola/calendula/issues/289
|
||||
[#290]: https://codeberg.org/jlmakiola/calendula/issues/290
|
||||
[#304]: https://codeberg.org/jlmakiola/calendula/issues/304
|
||||
[#321]: https://codeberg.org/jlmakiola/calendula/issues/321
|
||||
[#329]: https://codeberg.org/jlmakiola/calendula/issues/329
|
||||
|
||||
@@ -28,8 +28,8 @@ android {
|
||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||
versionCode = 22004
|
||||
versionName = "2.20.4"
|
||||
versionCode = 22100
|
||||
versionName = "2.21.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
+28
-15
@@ -1,22 +1,35 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
/**
|
||||
* Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` /
|
||||
* `EVENT_COLOR`. The named entries exist for callers that need one specific
|
||||
* hue (the managed special-dates calendars), so they can't drift from the
|
||||
* swatches offered in the colour picker.
|
||||
* The colours offered when creating or editing a calendar; ARGB ints for a raw
|
||||
* `CALENDAR_COLOR` / `EVENT_COLOR`.
|
||||
*
|
||||
* Hues rather than final fills: every surface that draws a calendar colour runs
|
||||
* it through the app's own tone pass first, so these are chosen to stay
|
||||
* distinguishable after it. Shared with Agendula, whose list palette these are —
|
||||
* the two apps are the same family and a calendar and a task list picked from
|
||||
* different-looking sets read as different products.
|
||||
*
|
||||
* The named entries exist for callers that need one specific hue (the managed
|
||||
* special-dates calendars), so they can't drift from the swatches on offer.
|
||||
*/
|
||||
object CalendarColorPalette {
|
||||
val Red = 0xFFD50000.toInt()
|
||||
val Orange = 0xFFE67C00.toInt()
|
||||
val Amber = 0xFFF6BF26.toInt()
|
||||
val Green = 0xFF33B679.toInt()
|
||||
val DarkGreen = 0xFF0B8043.toInt()
|
||||
val Blue = 0xFF039BE5.toInt()
|
||||
val Indigo = 0xFF3F51B5.toInt()
|
||||
val Purple = 0xFF8E24AA.toInt()
|
||||
val Graphite = 0xFF616161.toInt()
|
||||
val Mauve = 0xFF7A5C6B.toInt()
|
||||
val Red = 0xFFD7484A.toInt()
|
||||
val Orange = 0xFFE8743B.toInt()
|
||||
val Amber = 0xFFE0A32E.toInt()
|
||||
val Olive = 0xFF7CA83E.toInt()
|
||||
val Green = 0xFF35A06A.toInt()
|
||||
val Teal = 0xFF19938C.toInt()
|
||||
val Cyan = 0xFF2A9BC4.toInt()
|
||||
val Blue = 0xFF3C74C8.toInt()
|
||||
val Indigo = 0xFF6A5CC0.toInt()
|
||||
val Purple = 0xFF9455B8.toInt()
|
||||
val Pink = 0xFFC94F8E.toInt()
|
||||
|
||||
/** The full palette, in swatch-row order. */
|
||||
val all: List<Int> = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite)
|
||||
/** The full palette, in swatch-grid order. */
|
||||
val all: List<Int> = listOf(
|
||||
Mauve, Red, Orange, Amber, Olive, Green,
|
||||
Teal, Cyan, Blue, Indigo, Purple, Pink,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -376,7 +376,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
.build()
|
||||
|
||||
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||
val name = displayName.trim()
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||
@@ -400,7 +400,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
|
||||
override fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) {
|
||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||
val name = displayName.trim()
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
||||
put(CalendarContract.Calendars.NAME, name)
|
||||
@@ -464,7 +464,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||
val name = displayName.trim()
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||
|
||||
@@ -8,8 +8,9 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
||||
val isLocal = accountType == CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||
return CalendarSource(
|
||||
id = getLong(CalendarProjection.IDX_ID),
|
||||
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
|
||||
?: Fallbacks.UNNAMED_CALENDAR,
|
||||
// Blank when the provider row has no name: the placeholder is a display
|
||||
// string and belongs to the UI, where it can be localised (#329).
|
||||
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME).orEmpty(),
|
||||
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
|
||||
accountType = accountType,
|
||||
color = getInt(CalendarProjection.IDX_COLOR),
|
||||
|
||||
@@ -21,8 +21,9 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
||||
return null
|
||||
}
|
||||
|
||||
val rawTitle = getString(InstanceProjection.IDX_TITLE)
|
||||
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
||||
// Left blank when the event has no title: the placeholder is a display
|
||||
// string, so it belongs to the UI layer where it can be localised (#321).
|
||||
val title = getString(InstanceProjection.IDX_TITLE).orEmpty()
|
||||
|
||||
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
|
||||
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
|
||||
|
||||
@@ -312,8 +312,3 @@ internal object ReminderProjection {
|
||||
const val IDX_MINUTES = 0
|
||||
const val IDX_METHOD = 1
|
||||
}
|
||||
|
||||
internal object Fallbacks {
|
||||
const val UNNAMED_CALENDAR = "(Unbenannter Kalender)"
|
||||
const val UNTITLED_EVENT = "(Ohne Titel)"
|
||||
}
|
||||
|
||||
@@ -21,8 +21,7 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
||||
}.coerceAtLeast(dtStart)
|
||||
|
||||
val rawTitle = getString(SearchProjection.IDX_TITLE)
|
||||
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
||||
val title = getString(SearchProjection.IDX_TITLE).orEmpty()
|
||||
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
|
||||
getInt(SearchProjection.IDX_CALENDAR_COLOR)
|
||||
} else {
|
||||
|
||||
@@ -586,6 +586,24 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the wizard's calendar step has been answered (or skipped). */
|
||||
val onboardingCalendarsDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_CALENDARS_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingCalendarsDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_CALENDARS_KEY] = done }
|
||||
}
|
||||
|
||||
/** Whether the wizard's hidden-calendars step has been answered (or skipped). */
|
||||
val onboardingHiddenCalendarsDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_HIDDEN_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingHiddenCalendarsDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_HIDDEN_KEY] = done }
|
||||
}
|
||||
|
||||
/** Whether the wizard's backup step has been answered (or skipped). */
|
||||
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_BACKUP_KEY] ?: false
|
||||
@@ -977,6 +995,10 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||
internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
|
||||
internal val ONBOARDING_CALENDARS_KEY =
|
||||
booleanPreferencesKey("onboarding_calendars_done")
|
||||
internal val ONBOARDING_HIDDEN_KEY =
|
||||
booleanPreferencesKey("onboarding_hidden_calendars_done")
|
||||
internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
|
||||
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
|
||||
internal val ONBOARDING_MONTH_STYLE_KEY =
|
||||
|
||||
@@ -312,7 +312,7 @@ private fun ExportCalendarPicker(
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
val isSelected = calendar.id in selected
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
summary = calendar.description,
|
||||
position = positionOf(index, calendars.size),
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
|
||||
@@ -90,6 +90,7 @@ import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||
@@ -97,11 +98,25 @@ import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||
|
||||
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||
@@ -231,7 +246,7 @@ private fun CalendarsList(
|
||||
local.forEachIndexed { index, calendar ->
|
||||
val disabled = !calendar.isVisibleInSystem
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
summary = calendarRowSummary(calendar),
|
||||
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
@@ -239,7 +254,7 @@ private fun CalendarsList(
|
||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||
trailing = {
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName,
|
||||
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
enabled = !disabled,
|
||||
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
||||
)
|
||||
@@ -316,7 +331,7 @@ private fun CalendarsList(
|
||||
ordered.forEachIndexed { index, calendar ->
|
||||
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
summary = calendarRowSummary(calendar),
|
||||
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
@@ -325,7 +340,7 @@ private fun CalendarsList(
|
||||
trailing = if (calendar.hasVisibilitySwitch) {
|
||||
{
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName,
|
||||
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
enabled = calendar.isVisibleInSystem,
|
||||
onToggle = { enabled ->
|
||||
onSetVisible(calendar.id, enabled)
|
||||
@@ -343,9 +358,15 @@ private fun CalendarsList(
|
||||
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private const val SWATCHES_PER_ROW = 6
|
||||
|
||||
/**
|
||||
* Create or edit a local calendar: a name field over the palette of calendar
|
||||
* colours, on the family's full-screen sheet with the commit in its title bar.
|
||||
* The same sheet Agendula edits a task list in, so the two read as one product.
|
||||
*/
|
||||
@Composable
|
||||
private fun CalendarEditor(
|
||||
internal fun CalendarEditor(
|
||||
sessionKey: Int,
|
||||
isNew: Boolean,
|
||||
initialName: String,
|
||||
@@ -360,121 +381,50 @@ private fun CalendarEditor(
|
||||
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
||||
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
|
||||
var confirmDelete by remember { mutableStateOf(false) }
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.predictiveBack(onBack = onClose)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (isNew) R.string.calendars_new_title
|
||||
else R.string.calendars_edit_title,
|
||||
),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.event_edit_close),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (!isNew) {
|
||||
// Disabled rather than hidden while the special-dates
|
||||
// sync owns this calendar; the card below says why.
|
||||
IconButton(
|
||||
onClick = { confirmDelete = true },
|
||||
enabled = !deleteLocked,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.event_detail_delete),
|
||||
tint = if (deleteLocked) {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
// Filled save button, matching the event editor's top bar.
|
||||
Button(
|
||||
onClick = {
|
||||
onSave(name.trim(), color, description.trim().ifEmpty { null })
|
||||
},
|
||||
enabled = name.isNotBlank(),
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.event_edit_save))
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
val valid = name.isNotBlank()
|
||||
val commit = { if (valid) onSave(name.trim(), color, description.trim().ifEmpty { null }) }
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(
|
||||
if (isNew) R.string.calendars_new_title else R.string.calendars_edit_title,
|
||||
),
|
||||
onDismiss = onClose,
|
||||
predictiveBack = true,
|
||||
actions = {
|
||||
Button(
|
||||
onClick = commit,
|
||||
enabled = valid,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
) { Text(stringResource(R.string.event_edit_save)) }
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (deleteLocked) {
|
||||
EditorCard(
|
||||
icon = Icons.Default.Info,
|
||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
iconAtTop = true,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_managed_delete_locked),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) {
|
||||
InlineTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
placeholder = stringResource(R.string.calendars_name_label),
|
||||
textStyle = MaterialTheme.typography.titleLarge,
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
)
|
||||
}
|
||||
EditorCard(
|
||||
icon = Icons.Default.Palette,
|
||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
iconAtTop = true,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_color_label),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
ColorSwatchRow(
|
||||
colors = CalendarColorPalette.all,
|
||||
selected = color,
|
||||
onSelect = { color = it },
|
||||
dark = dark,
|
||||
)
|
||||
}
|
||||
EditorCard(
|
||||
icon = Icons.AutoMirrored.Filled.Notes,
|
||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
iconAtTop = true,
|
||||
) {
|
||||
) {
|
||||
if (deleteLocked) {
|
||||
ManagedNote()
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
CalendarNameField(
|
||||
name = name,
|
||||
color = color,
|
||||
focusRequester = focusRequester,
|
||||
onNameChange = { name = it },
|
||||
onImeAction = commit,
|
||||
)
|
||||
// Inside the picker: it is a Dialog, so a request made from the caller's
|
||||
// composition can run before this field's node exists.
|
||||
// A new calendar opens with the keyboard up: naming it is the whole task.
|
||||
LaunchedEffect(isNew) { if (isNew) focusRequester.requestFocus() }
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
EditorSectionLabel(stringResource(R.string.calendars_color_label))
|
||||
CalendarColorGrid(selected = color, onSelect = { color = it })
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
EditorSectionLabel(stringResource(R.string.calendars_description_label))
|
||||
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
Box(Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(16.dp)) {
|
||||
InlineTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
@@ -485,20 +435,21 @@ private fun CalendarEditor(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isNew && !deleteLocked) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
DeleteCalendarRow(onClick = { confirmDelete = true })
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
if (confirmDelete) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmDelete = false },
|
||||
title = { Text(stringResource(R.string.calendars_delete_confirm_title)) },
|
||||
text = {
|
||||
Text(stringResource(R.string.calendars_delete_confirm_message, initialName))
|
||||
},
|
||||
text = { Text(stringResource(R.string.calendars_delete_confirm_message, initialName)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
confirmDelete = false
|
||||
onDelete()
|
||||
}) {
|
||||
TextButton(onClick = { confirmDelete = false; onDelete() }) {
|
||||
Text(
|
||||
stringResource(R.string.event_detail_delete),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
@@ -514,6 +465,205 @@ private fun CalendarEditor(
|
||||
}
|
||||
}
|
||||
|
||||
/** The name, with the chosen colour beside it so the two read as one thing. */
|
||||
@Composable
|
||||
private fun CalendarNameField(
|
||||
name: String,
|
||||
color: Int,
|
||||
focusRequester: FocusRequester,
|
||||
onNameChange: (String) -> Unit,
|
||||
onImeAction: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CalendarMonth,
|
||||
contentDescription = null,
|
||||
tint = eventAccent(color, dark, soften),
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
InlineTextField(
|
||||
value = name,
|
||||
onValueChange = onNameChange,
|
||||
placeholder = stringResource(R.string.calendars_name_label),
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
imeAction = ImeAction.Done,
|
||||
onImeAction = onImeAction,
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The palette as two rows of round swatches; the chosen one carries a check.
|
||||
*
|
||||
* Drawn through the app's own tone pass rather than raw, so a swatch is the
|
||||
* colour the calendar's events will actually appear in — the user's "soften
|
||||
* colours" preference included.
|
||||
*/
|
||||
@Composable
|
||||
private fun CalendarColorGrid(selected: Int, onSelect: (Int) -> Unit) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp, vertical = 16.dp)
|
||||
.selectableGroup(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
CalendarColorPalette.all.chunked(SWATCHES_PER_ROW).forEach { row ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
row.forEach { swatch ->
|
||||
ColorSwatch(
|
||||
fill = eventFill(swatch, dark, soften),
|
||||
label = stringResource(swatchLabel(swatch)),
|
||||
selected = swatch == selected,
|
||||
onClick = { onSelect(swatch) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
// Keeps a short final row's swatches the size a full row's
|
||||
// are, rather than stretching them across the width.
|
||||
repeat(SWATCHES_PER_ROW - row.size) { Spacer(Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColorSwatch(
|
||||
fill: Color,
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.aspectRatio(1f)
|
||||
.clip(CircleShape)
|
||||
.background(fill)
|
||||
.then(
|
||||
if (selected) {
|
||||
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
// selectable, not clickable: the swatch carries its chosen state in
|
||||
// semantics too, so the check is not the only cue.
|
||||
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
|
||||
.semantics { contentDescription = label },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (selected) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = eventInk(fill, alpha = 0.7f),
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Names the swatch for a screen reader — twelve circles are otherwise one label. */
|
||||
@StringRes
|
||||
private fun swatchLabel(argb: Int): Int = when (argb) {
|
||||
CalendarColorPalette.Mauve -> R.string.color_name_mauve
|
||||
CalendarColorPalette.Red -> R.string.color_name_red
|
||||
CalendarColorPalette.Orange -> R.string.color_name_orange
|
||||
CalendarColorPalette.Amber -> R.string.color_name_amber
|
||||
CalendarColorPalette.Olive -> R.string.color_name_olive
|
||||
CalendarColorPalette.Green -> R.string.color_name_green
|
||||
CalendarColorPalette.Teal -> R.string.color_name_teal
|
||||
CalendarColorPalette.Cyan -> R.string.color_name_cyan
|
||||
CalendarColorPalette.Blue -> R.string.color_name_blue
|
||||
CalendarColorPalette.Indigo -> R.string.color_name_indigo
|
||||
CalendarColorPalette.Purple -> R.string.color_name_purple
|
||||
else -> R.string.color_name_pink
|
||||
}
|
||||
|
||||
/** Said in the sheet rather than on a disabled button: the sync owns this one. */
|
||||
@Composable
|
||||
private fun ManagedNote() {
|
||||
GroupedSurface(
|
||||
position = Position.Alone,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Info, contentDescription = null)
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_managed_delete_locked),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteCalendarRow(onClick: () -> Unit) {
|
||||
GroupedSurface(
|
||||
position = Position.Alone,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
onClick = onClick,
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(horizontal = 20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.event_detail_delete),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditorSectionLabel(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The row's supporting line: the states that make this calendar behave unlike a
|
||||
* plain writable one (#76), then its own description.
|
||||
@@ -556,36 +706,6 @@ private fun EnableSwitch(
|
||||
private fun dimIf(disabled: Boolean): Modifier =
|
||||
if (disabled) Modifier.alpha(0.38f) else Modifier
|
||||
|
||||
/** Tonal field card matching the event editor's design (icon + content). */
|
||||
@Composable
|
||||
private fun EditorCard(
|
||||
icon: ImageVector,
|
||||
iconTint: Color,
|
||||
iconAtTop: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = iconTint,
|
||||
modifier = Modifier
|
||||
.padding(top = if (iconAtTop) 2.dp else 0.dp)
|
||||
.size(24.dp),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One collapsible calendar group rendered as a connected card. The header row is
|
||||
|
||||
@@ -56,12 +56,14 @@ fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
|
||||
* account comes from when another account shares the name (#77).
|
||||
*/
|
||||
@Composable
|
||||
fun accountGroupTitle(group: CalendarAccountGroup): String =
|
||||
if (!group.ambiguous) {
|
||||
group.label
|
||||
fun accountGroupTitle(group: CalendarAccountGroup): String {
|
||||
val label = group.label.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||
return if (!group.ambiguous) {
|
||||
label
|
||||
} else {
|
||||
stringResource(R.string.calendars_account_from_source, group.label, sourceAppName(group.accountType))
|
||||
stringResource(R.string.calendars_account_from_source, label, sourceAppName(group.accountType))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The human name of the app backing [accountType], falling back to the raw
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlin.math.floor
|
||||
|
||||
/**
|
||||
* One all-day strip height for every page, blended between the two pages a swipe
|
||||
* sits between so their timelines stay level mid-swipe. It follows the swipe
|
||||
* directly and only springs when a page's own strip changes at rest. [fallback]
|
||||
* stands in until the pages have reported theirs, so it doesn't open from zero.
|
||||
*
|
||||
* @param heights each composed page's own strip height, by page index.
|
||||
*/
|
||||
@Composable
|
||||
fun pagedStripHeight(pagerState: PagerState, heights: Map<Int, Dp>, fallback: Dp): Dp {
|
||||
val currentFallback by rememberUpdatedState(fallback)
|
||||
val target by remember(pagerState, heights) {
|
||||
derivedStateOf {
|
||||
val position = pagerState.currentPage + pagerState.currentPageOffsetFraction
|
||||
val from = floor(position).toInt()
|
||||
val a = heights[from]
|
||||
val b = heights[from + 1]
|
||||
lerp(a ?: b ?: currentFallback, b ?: a ?: currentFallback, position - from)
|
||||
}
|
||||
}
|
||||
val height = remember { Animatable(target, Dp.VectorConverter) }
|
||||
LaunchedEffect(height) {
|
||||
snapshotFlow { target to pagerState.isScrollInProgress }.collectLatest { (h, scrolling) ->
|
||||
if (scrolling) height.snapTo(h) else height.animateTo(h)
|
||||
}
|
||||
}
|
||||
return height.value
|
||||
}
|
||||
|
||||
/** A deliberate jump (Today, jump-to-date): animated, or snapped under reduced motion. */
|
||||
suspend fun PagerState.jumpToPage(
|
||||
page: Int,
|
||||
reduceMotion: Boolean,
|
||||
spec: FiniteAnimationSpec<Float>,
|
||||
) {
|
||||
if (reduceMotion) scrollToPage(page) else animateScrollToPage(page, animationSpec = spec)
|
||||
}
|
||||
@@ -116,7 +116,7 @@ private fun CalendarPickerGroup(
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
val isSelected = calendar.id == selectedId
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
|
||||
selected = isSelected,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
|
||||
@@ -40,6 +40,12 @@ import androidx.compose.ui.unit.IntOffset
|
||||
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
||||
MaterialTheme.motionScheme.defaultSpatialSpec()
|
||||
|
||||
/** The same spring as [rememberCalendarSlideSpec], for a pager settling onto its page. */
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun rememberCalendarPageSpec(): FiniteAnimationSpec<Float> =
|
||||
MaterialTheme.motionScheme.defaultSpatialSpec()
|
||||
|
||||
/**
|
||||
* The effects spec from the active motion scheme, for the opacity half of the
|
||||
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
|
||||
/**
|
||||
* One shared state per pager page — a week, a day, a month — so a page and the
|
||||
* screen's anchor read the same query, and the pages either side stay loaded
|
||||
* across a swipe.
|
||||
*
|
||||
* Every entry shares on its own job, cancelled when [keep] lets it go: a
|
||||
* `stateIn` on the ViewModel's scope would stay running, holding its last state,
|
||||
* until the ViewModel itself was cleared. Main thread only.
|
||||
*
|
||||
* @param keep whether a cached key is still worth holding once [get] asks for another.
|
||||
*/
|
||||
class PageStateCache<K, S>(
|
||||
private val scope: CoroutineScope,
|
||||
private val initial: S,
|
||||
private val keep: (cached: K, requested: K) -> Boolean,
|
||||
private val load: (K) -> Flow<S>,
|
||||
) {
|
||||
private class Entry<S>(val state: StateFlow<S>, val job: Job)
|
||||
|
||||
private val entries = HashMap<K, Entry<S>>()
|
||||
|
||||
/** The number of entries held, for tests. */
|
||||
internal val size: Int get() = entries.size
|
||||
|
||||
fun get(key: K): StateFlow<S> {
|
||||
val iterator = entries.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val (cached, entry) = iterator.next()
|
||||
if (cached != key && !keep(cached, key)) {
|
||||
entry.job.cancel()
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
return entries.getOrPut(key) {
|
||||
val job = SupervisorJob(scope.coroutineContext[Job])
|
||||
Entry(
|
||||
state = load(key).stateIn(scope + job, SharingStarted.WhileSubscribed(5_000L), initial),
|
||||
job = job,
|
||||
)
|
||||
}.state
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.plus
|
||||
|
||||
/** Pages the day pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */
|
||||
internal const val DAY_PAGE_COUNT: Int = 140_000
|
||||
|
||||
private const val EPOCH_PAGE = DAY_PAGE_COUNT / 2
|
||||
|
||||
/** The day at [EPOCH_PAGE]; any date works, it only has to stay fixed. */
|
||||
private val PAGE_EPOCH = LocalDate(2000, 1, 1)
|
||||
|
||||
/** The day shown on pager [page]. */
|
||||
internal fun dayForPage(page: Int): LocalDate =
|
||||
PAGE_EPOCH.plus(page - EPOCH_PAGE, DateTimeUnit.DAY)
|
||||
|
||||
/** The pager page showing [date]. */
|
||||
internal fun dayPageFor(date: LocalDate): Int =
|
||||
(EPOCH_PAGE + PAGE_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1)
|
||||
@@ -1,7 +1,22 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerDefaults
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
|
||||
import de.jeanlucmakiola.calendula.ui.common.pagedStripHeight
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
@@ -43,12 +58,10 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -109,14 +122,11 @@ import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
||||
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
||||
@@ -137,7 +147,6 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -145,6 +154,9 @@ import kotlin.math.roundToInt
|
||||
private val ALL_DAY_ROW_HEIGHT = 20.dp
|
||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||
|
||||
/** Breathing room between the all-day strip and the scrolling timeline below. */
|
||||
private val TIMELINE_TOP_GAP = 8.dp
|
||||
|
||||
/** Total all-day strip height for the day (0 when there are no all-day events). */
|
||||
internal fun DayUiState.Success.allDayStripHeight(): Dp {
|
||||
if (allDay.isEmpty()) return 0.dp
|
||||
@@ -169,7 +181,7 @@ fun DayScreen(
|
||||
viewModel: DayViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val date by viewModel.date.collectAsStateWithLifecycle()
|
||||
val anchorPage by viewModel.anchorPage.collectAsStateWithLifecycle()
|
||||
|
||||
// When opened from the month grid, anchor to the tapped date.
|
||||
LaunchedEffect(initialDateIso) {
|
||||
@@ -179,36 +191,39 @@ fun DayScreen(
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val isOnToday = when (val s = state) {
|
||||
is DayUiState.Success -> s.date == s.today
|
||||
else -> true
|
||||
}
|
||||
|
||||
// Drives whether the title carries the year. Falls back to the clock only
|
||||
// while the first load is in flight, when there is no state to read today from.
|
||||
val currentYear = when (val s = state) {
|
||||
is DayUiState.Success -> s.today.year
|
||||
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
||||
}
|
||||
|
||||
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
|
||||
var slideDir by remember { mutableIntStateOf(0) }
|
||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||
val goPrev = { slideDir = -1; viewModel.goToPrev() }
|
||||
// Slide toward today: viewing the future → today comes in from the left
|
||||
// (back), viewing the past → from the right (forward).
|
||||
val jumpToToday = {
|
||||
slideDir = when (val s = state) {
|
||||
is DayUiState.Success -> if (s.today < s.date) -1 else 1
|
||||
else -> 0
|
||||
// Opens on the tapped date directly, rather than on today and then correcting.
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = initialDateIso?.let { dayPageFor(LocalDate.parse(it)) } ?: anchorPage,
|
||||
) { DAY_PAGE_COUNT }
|
||||
val pageSpec = rememberCalendarPageSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
// The pager leads and the anchor follows once it settles, so the anchor only
|
||||
// moves on its own to correct the pager — e.g. re-entry from the month grid
|
||||
// on another date. Snapped, since that is a correction rather than a move.
|
||||
LaunchedEffect(anchorPage) {
|
||||
if (pagerState.currentPage != anchorPage && !pagerState.isScrollInProgress) {
|
||||
pagerState.scrollToPage(anchorPage)
|
||||
}
|
||||
viewModel.goToToday()
|
||||
}
|
||||
// Drawer jump-to-date: slide from the side the target lies on.
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled)
|
||||
}
|
||||
// The day under the finger, so the title turns over mid-swipe, not after it.
|
||||
val date = dayForPage(pagerState.currentPage)
|
||||
|
||||
// Off the ticking clock rather than the loaded day, so it turns over at
|
||||
// midnight; derived, so the per-minute tick doesn't recompose the screen.
|
||||
val nowState = rememberCurrentMinute()
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } }
|
||||
val isOnToday = state !is DayUiState.Success || date == today
|
||||
|
||||
// Straight to the pager: a tap mid-fling must still land, and the anchor
|
||||
// hasn't caught up with the fling yet to tell the difference.
|
||||
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||
slideDir = if (target < date) -1 else 1
|
||||
viewModel.goToDate(target)
|
||||
scope.launch { pagerState.jumpToPage(dayPageFor(target), reduceMotion, pageSpec) }
|
||||
}
|
||||
val jumpToToday = { jumpToDate(today) }
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -240,7 +255,7 @@ fun DayScreen(
|
||||
topBar = {
|
||||
DayTopBar(
|
||||
date = date,
|
||||
currentYear = currentYear,
|
||||
currentYear = today.year,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
@@ -262,10 +277,9 @@ fun DayScreen(
|
||||
) { innerPadding ->
|
||||
DayContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
pagerState = pagerState,
|
||||
day = viewModel::day,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||
modifier = Modifier
|
||||
@@ -279,19 +293,16 @@ fun DayScreen(
|
||||
@Composable
|
||||
private fun DayContent(
|
||||
state: DayUiState,
|
||||
slideDir: Int,
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
pagerState: PagerState,
|
||||
day: (LocalDate) -> StateFlow<DayUiState>,
|
||||
onRetry: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
|
||||
// Hoisted above the per-day AnimatedContent so the vertical scroll position
|
||||
// Shared by every page and the gutter, so the vertical scroll position
|
||||
// survives day-to-day swipes. We only centre on noon once, on first entry
|
||||
// into the day view (i.e. when arriving from the month/week view).
|
||||
val scrollState = rememberScrollState()
|
||||
@@ -302,59 +313,45 @@ private fun DayContent(
|
||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||
}
|
||||
|
||||
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
||||
// day during a swipe, so the strip slides along but never jumps in height.
|
||||
val targetAllDayHeight = (state as? DayUiState.Success)?.allDayStripHeight() ?: 0.dp
|
||||
val allDayHeight by animateDpAsState(
|
||||
targetValue = targetAllDayHeight,
|
||||
label = "day-all-day-strip-height",
|
||||
)
|
||||
|
||||
// Whole-page horizontal swipe, one level above the timeline's vertical
|
||||
// scroll: a horizontal drag crosses this detector's slop, while a vertical
|
||||
// drag is consumed by the inner scroll first — the two gestures coexist.
|
||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||
|
||||
// Above the AnimatedContent: a page change mid-drag would strand the
|
||||
// floating block inside the outgoing page.
|
||||
// Above the pager: a page change mid-drag would strand the floating block
|
||||
// inside the outgoing page.
|
||||
val dragController = rememberTimelineDragController()
|
||||
val move = LocalEventMove.current
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val onDrop: (TimelineDrop) -> Unit = { drop ->
|
||||
move?.move(
|
||||
MoveRequest(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
// Loading and failure concern the provider as a whole, so they stand in
|
||||
// for the pager; a page still loading its own day handles that itself.
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
when (s) {
|
||||
is DayUiState.Success -> "success-${s.date}"
|
||||
is DayUiState.Failure -> "failure-${s.reason}"
|
||||
DayUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "day-transition",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentKey = { it::class },
|
||||
transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) },
|
||||
label = "day-state",
|
||||
) { s ->
|
||||
when (s) {
|
||||
DayUiState.Loading -> DayLoading()
|
||||
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is DayUiState.Success -> DaySuccess(
|
||||
state = s,
|
||||
is DayUiState.Success -> DayPager(
|
||||
pagerState = pagerState,
|
||||
day = day,
|
||||
today = s.today,
|
||||
initialStripHeight = s.allDayStripHeight(),
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = { drop ->
|
||||
move?.move(
|
||||
MoveRequest(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||
),
|
||||
)
|
||||
},
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -362,6 +359,101 @@ private fun DayContent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The days as pages that follow the finger and snap once a swipe passes half a
|
||||
* page or is flung (#336), beside a gutter that stays put.
|
||||
*/
|
||||
@Composable
|
||||
private fun DayPager(
|
||||
pagerState: PagerState,
|
||||
day: (LocalDate) -> StateFlow<DayUiState>,
|
||||
today: LocalDate,
|
||||
initialStripHeight: Dp,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
|
||||
val allDayHeight = pagedStripHeight(pagerState, stripHeights, initialStripHeight)
|
||||
DayFrame(allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
// The days either side are composed ahead, so a swipe lands on a
|
||||
// loaded day rather than watching it fill in.
|
||||
beyondViewportPageCount = 1,
|
||||
// A held block moves within its day; the page stays put under it.
|
||||
userScrollEnabled = !dragController.isDragging,
|
||||
flingBehavior = PagerDefaults.flingBehavior(
|
||||
state = pagerState,
|
||||
snapAnimationSpec = rememberCalendarPageSpec(),
|
||||
),
|
||||
) { page ->
|
||||
val date = dayForPage(page)
|
||||
val pageState by remember(date) { day(date) }.collectAsStateWithLifecycle()
|
||||
val loaded = pageState as? DayUiState.Success
|
||||
// Until its day arrives a page shows the empty column.
|
||||
val empty = remember(date, today) {
|
||||
DayUiState.Success(date = date, today = today, allDay = emptyList(), timed = emptyList())
|
||||
}
|
||||
val stripHeight = loaded?.allDayStripHeight()
|
||||
DisposableEffect(page, stripHeight) {
|
||||
if (stripHeight != null) stripHeights[page] = stripHeight
|
||||
onDispose { stripHeights.remove(page) }
|
||||
}
|
||||
DayPage(
|
||||
state = loaded ?: empty,
|
||||
allDayHeight = allDayHeight,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
active = page == pagerState.currentPage,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What stays put while the days swipe: the hour gutter, with [pages] filling the rest. */
|
||||
@Composable
|
||||
private fun DayFrame(
|
||||
allDayHeight: Dp,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
pages: @Composable () -> Unit,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.width(GUTTER_WIDTH).fillMaxHeight()) {
|
||||
Spacer(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(allDayHeight)
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
)
|
||||
Spacer(Modifier.height(TIMELINE_TOP_GAP))
|
||||
// Resolves the hour height off the same viewport height the pages'
|
||||
// timelines have, so labels and column agree.
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val zoom = LocalTimelineZoom.current
|
||||
val hourHeight = zoom.scale.hourHeight(maxHeight)
|
||||
// Start inset so the labels centre on the top bar hamburger,
|
||||
// matching the week view.
|
||||
HourGutter(
|
||||
scrollState = scrollState,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
modifier = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom),
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f).fillMaxHeight()) { pages() }
|
||||
}
|
||||
}
|
||||
|
||||
/** A single day in its frame, without the pager — for the Settings preview. */
|
||||
@Composable
|
||||
internal fun DaySuccess(
|
||||
state: DayUiState.Success,
|
||||
@@ -371,6 +463,37 @@ internal fun DaySuccess(
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
DayFrame(allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController) {
|
||||
DayPage(
|
||||
state = state,
|
||||
allDayHeight = allDayHeight,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
active = true,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One day's page: all-day strip and column.
|
||||
*
|
||||
* @param active whether this is the page on screen, the only one that may
|
||||
* publish the drag geometry.
|
||||
*/
|
||||
@Composable
|
||||
private fun DayPage(
|
||||
state: DayUiState.Success,
|
||||
allDayHeight: Dp,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
active: Boolean,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// All-day strip collapses to nothing when the day has no all-day events,
|
||||
@@ -389,13 +512,12 @@ internal fun DaySuccess(
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
)
|
||||
}
|
||||
// Breathing room between the top section and the scrolling timeline
|
||||
// below.
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Spacer(Modifier.height(TIMELINE_TOP_GAP))
|
||||
Timeline(
|
||||
state = state,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
active = active,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
@@ -483,8 +605,6 @@ private fun AllDayStrip(
|
||||
end = TIMELINE_CONTENT_END_INSET,
|
||||
),
|
||||
) {
|
||||
// Keep the gutter-width offset so the bars line up with the day column.
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
// Bars are positioned absolutely by lane (vertical stacking); each spans
|
||||
// the full day-column width. clipToBounds keeps bars from spilling out
|
||||
// while the height animates.
|
||||
@@ -547,6 +667,7 @@ private fun Timeline(
|
||||
state: DayUiState.Success,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
active: Boolean,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
@@ -568,61 +689,53 @@ private fun Timeline(
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val hourHeight = zoom.scale.hourHeight(maxHeight)
|
||||
val totalHeight = hourHeight * 24
|
||||
// The pinch sits on the Row, above both scroll viewports: it has to
|
||||
// outrank the vertical scroll, and it does that by watching the initial
|
||||
// pass, which only reaches it if it is their ancestor.
|
||||
// The pinch sits above the scroll viewport: it has to outrank the
|
||||
// vertical scroll, and it does that by watching the initial pass, which
|
||||
// only reaches it if it is the viewport's ancestor.
|
||||
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
|
||||
// Gutter and day column are two scroll viewports that SHARE one scroll
|
||||
// state, so they stay perfectly aligned. The day-column viewport is a
|
||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||
// soft corners are permanent at any scroll position.
|
||||
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
||||
// Hour gutter (scrolls in sync with the day column). Start inset so the
|
||||
// labels centre on the top bar hamburger, matching the week view.
|
||||
HourGutter(
|
||||
scrollState = scrollState,
|
||||
// Scrolls on the same state as the gutter and the other pages, so they
|
||||
// all stay aligned. A static, rounded-clipped window — the content
|
||||
// scrolls inside it, so the soft corners are permanent at any scroll
|
||||
// position.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(pinch)
|
||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.verticalScroll(scrollState)
|
||||
.onGloballyPositioned { if (active) dragController.geometry.viewport = it },
|
||||
) {
|
||||
DayColumnCard(
|
||||
blocks = state.timed,
|
||||
dark = dark,
|
||||
date = state.date,
|
||||
today = state.today,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
)
|
||||
// Day column: rounded, clipped scroll viewport (permanent corners).
|
||||
Box(
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.verticalScroll(scrollState)
|
||||
.onGloballyPositioned { dragController.geometry.viewport = it },
|
||||
) {
|
||||
DayColumnCard(
|
||||
blocks = state.timed,
|
||||
dark = dark,
|
||||
date = state.date,
|
||||
today = state.today,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(totalHeight)
|
||||
// The scrolling content itself, so its root position
|
||||
// already folds in the scroll offset.
|
||||
.onGloballyPositioned { coords ->
|
||||
dragController.geometry.let {
|
||||
it.grid = coords
|
||||
it.scroll = scrollState
|
||||
it.hourPx = with(density) { hourHeight.toPx() }
|
||||
it.blockInsetPx = blockInsetPx
|
||||
it.columnGapPx = 0f
|
||||
it.columnWidthPx = coords.size.width.toFloat()
|
||||
it.days = listOf(state.date)
|
||||
it.isRtl = isRtl
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
.fillMaxWidth()
|
||||
.height(totalHeight)
|
||||
// The scrolling content itself, so its root position
|
||||
// already folds in the scroll offset. Only the page on
|
||||
// screen publishes it: the ones either side are laid out too.
|
||||
.onGloballyPositioned { coords ->
|
||||
if (!active) return@onGloballyPositioned
|
||||
dragController.geometry.let {
|
||||
it.grid = coords
|
||||
it.scroll = scrollState
|
||||
it.hourPx = with(density) { hourHeight.toPx() }
|
||||
it.blockInsetPx = blockInsetPx
|
||||
it.columnGapPx = 0f
|
||||
it.columnWidthPx = coords.size.width.toFloat()
|
||||
it.days = listOf(state.date)
|
||||
it.isRtl = isRtl
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -18,21 +19,25 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNot
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.abs
|
||||
|
||||
/** How far from the requested day [DayViewModel.day] keeps other days cached. */
|
||||
private const val DAY_CACHE_DAYS = 7
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
@@ -47,32 +52,53 @@ class DayViewModel @Inject constructor(
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
private val _date = MutableStateFlow(todayDate)
|
||||
val date: StateFlow<LocalDate> = _date
|
||||
|
||||
/** The pager page the anchor day sits on. */
|
||||
val anchorPage: StateFlow<Int> = _date
|
||||
.map { dayPageFor(it) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = dayPageFor(todayDate),
|
||||
)
|
||||
|
||||
/**
|
||||
* The anchor day's state. Once loaded it never falls back to [DayUiState.Loading]:
|
||||
* moving the anchor keeps the last result until the new day arrives, since
|
||||
* the pages draw their own days and this only gates the failure screen.
|
||||
*/
|
||||
val state: StateFlow<DayUiState> = _date
|
||||
.flatMapLatest { day ->
|
||||
val range = dayRange(day, zone)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
) { calendars, instances ->
|
||||
buildState(day, calendars, instances)
|
||||
}
|
||||
}
|
||||
.flatMapLatest { date -> day(date).filterNot { it is DayUiState.Loading } }
|
||||
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = DayUiState.Loading,
|
||||
)
|
||||
|
||||
fun goToPrev() {
|
||||
_date.value = _date.value.minus(1, DateTimeUnit.DAY)
|
||||
private val days = PageStateCache<LocalDate, DayUiState>(
|
||||
scope = viewModelScope,
|
||||
initial = DayUiState.Loading,
|
||||
// Far enough to cover the pages either side and a swipe back, so paging
|
||||
// through a month doesn't keep a month of queries around.
|
||||
keep = { cached, requested -> abs(cached.daysUntil(requested)) <= DAY_CACHE_DAYS },
|
||||
) { date ->
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(dayRange(date, zone)),
|
||||
) { calendars, instances ->
|
||||
buildState(date, calendars, instances)
|
||||
}
|
||||
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
}
|
||||
|
||||
fun goToNext() {
|
||||
_date.value = _date.value.plus(1, DateTimeUnit.DAY)
|
||||
/** The day [date], shared between its pager page and [state]. Main thread only. */
|
||||
fun day(date: LocalDate): StateFlow<DayUiState> = days.get(date)
|
||||
|
||||
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
|
||||
fun onPageSettled(page: Int) {
|
||||
if (dayPageFor(_date.value) != page) _date.value = dayForPage(page)
|
||||
}
|
||||
|
||||
fun goToToday() {
|
||||
|
||||
@@ -527,7 +527,8 @@ private fun EventDetailContent(
|
||||
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
||||
) {
|
||||
Text(
|
||||
text = state.calendarName ?: stringResource(R.string.event_detail_calendar_unknown),
|
||||
text = state.calendarName?.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||
?: stringResource(R.string.event_detail_calendar_unknown),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -620,7 +620,9 @@ private fun EventEditContent(
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.event_edit_managed_hint,
|
||||
selectedCalendar?.displayName.orEmpty(),
|
||||
selectedCalendar?.displayName
|
||||
?.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||
.orEmpty(),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -719,6 +721,7 @@ private fun EventEditContent(
|
||||
) {
|
||||
Text(
|
||||
text = selectedCalendar?.displayName
|
||||
?.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||
?: stringResource(R.string.event_edit_error_no_calendar),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (selectedCalendar == null) {
|
||||
|
||||
@@ -62,15 +62,16 @@ private fun FilterList(
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
groups.forEach { group ->
|
||||
val account = group.account.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||
Text(
|
||||
text = if (group.ambiguous) {
|
||||
stringResource(
|
||||
R.string.calendars_account_from_source,
|
||||
group.account,
|
||||
account,
|
||||
sourceAppName(group.accountType),
|
||||
)
|
||||
} else {
|
||||
group.account
|
||||
account
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -83,7 +84,7 @@ private fun FilterList(
|
||||
)
|
||||
group.calendars.forEachIndexed { index, cal ->
|
||||
GroupedRow(
|
||||
title = cal.displayName,
|
||||
title = cal.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
position = positionOf(index, group.calendars.size),
|
||||
minHeight = 56.dp,
|
||||
leading = { CalendarColorChip(cal.color) },
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.CloudSync
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarEditor
|
||||
import de.jeanlucmakiola.calendula.ui.settings.openUrl
|
||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
|
||||
/** Where the sync branch sends someone who needs a CalDAV client (#287). */
|
||||
private const val DAVX5_URL = "https://www.davx5.com/"
|
||||
|
||||
/**
|
||||
* Wizard step shown only when the device has no calendar at all (#287): the app
|
||||
* would otherwise finish onboarding onto an empty grid with nothing saying why.
|
||||
*
|
||||
* Two ways out. Calendula writes to the system calendar provider and syncs
|
||||
* nothing itself, so anything beyond a device-only calendar means pointing at
|
||||
* whatever does the syncing.
|
||||
*
|
||||
* [creation] switches the step to the outcome of its own create: confirming
|
||||
* the calendar — the one place to say that more of them live in Settings — or
|
||||
* reporting that the provider refused it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun CalendarsStep(
|
||||
onCreateLocal: (name: String, color: Int, description: String?) -> Unit,
|
||||
onSkip: () -> Unit,
|
||||
creation: CalendarCreation?,
|
||||
onFinished: () -> Unit,
|
||||
onClearCreation: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
// The same editor Settings opens, rather than a calendar conjured out of
|
||||
// nowhere: "create a calendar" should let you name and colour it, and the
|
||||
// step is the first place anyone meets one.
|
||||
var creating by rememberSaveable { mutableStateOf(false) }
|
||||
// The insert is a suspend write: the editor stays up until it lands, so the
|
||||
// step can't flash its "create" button back for a second press.
|
||||
var submitted by rememberSaveable(creating) { mutableStateOf(false) }
|
||||
LaunchedEffect(creation) { if (creation is CalendarCreation.Failed) creating = false }
|
||||
|
||||
if (creation is CalendarCreation.Created) {
|
||||
CalendarCreated(
|
||||
name = creation.name,
|
||||
onContinue = onFinished,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (creating) {
|
||||
CalendarEditor(
|
||||
sessionKey = 0,
|
||||
isNew = true,
|
||||
// Empty, not a suggestion: a name already in the field reads as one
|
||||
// the app has chosen, and the first thing to do is clear it.
|
||||
initialName = "",
|
||||
initialColor = CalendarColorPalette.all.first(),
|
||||
initialDescription = "",
|
||||
onSave = { name, color, description ->
|
||||
if (!submitted) {
|
||||
submitted = true
|
||||
onCreateLocal(name, color, description)
|
||||
}
|
||||
},
|
||||
onDelete = {},
|
||||
onClose = { creating = false },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { IconHero(Icons.Filled.CalendarMonth) },
|
||||
actions = {
|
||||
Button(
|
||||
onClick = {
|
||||
onClearCreation()
|
||||
creating = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_calendars_local_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { openUrl(context, DAVX5_URL) },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.onboarding_calendars_caldav_button))
|
||||
}
|
||||
TextButton(
|
||||
onClick = onSkip,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.onboarding_calendars_skip_button))
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_calendars_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_calendars_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
if (creation is CalendarCreation.Failed) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_calendars_create_failed),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||
|
||||
BenefitRow(
|
||||
icon = Icons.Filled.PhoneAndroid,
|
||||
title = stringResource(R.string.onboarding_calendars_benefit_local_title),
|
||||
body = stringResource(R.string.onboarding_calendars_benefit_local_body),
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||
BenefitRow(
|
||||
icon = Icons.Filled.CloudSync,
|
||||
title = stringResource(R.string.onboarding_calendars_benefit_sync_title),
|
||||
body = stringResource(R.string.onboarding_calendars_benefit_sync_body),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** What the step shows once the calendar exists: that it does, and where the next one comes from. */
|
||||
@Composable
|
||||
private fun CalendarCreated(
|
||||
name: String,
|
||||
onContinue: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { IconHero(Icons.Filled.CheckCircle) },
|
||||
actions = {
|
||||
Button(
|
||||
onClick = onContinue,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_calendars_created_continue),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_calendars_created_title, name),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_calendars_created_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||
|
||||
BenefitRow(
|
||||
icon = Icons.Filled.Settings,
|
||||
title = stringResource(R.string.onboarding_calendars_created_more_title),
|
||||
body = stringResource(R.string.onboarding_calendars_created_more_body),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/**
|
||||
* Wizard step for a device whose every calendar is switched off (#287), which
|
||||
* would otherwise open the app on a blank grid with nothing saying why.
|
||||
*
|
||||
* The calendars are listed rather than summarised: which ones exist and whose
|
||||
* account they came from is the whole of what there is to decide, and switching
|
||||
* them on shows them in every other calendar app too — not a choice to make on
|
||||
* the app's word. Each row carries the same switch Settings does; the button
|
||||
* above them does all of it at once.
|
||||
*
|
||||
* Declining leads into the calendar step behind this one, which offers to make a
|
||||
* calendar instead — the same offer a device with none at all gets.
|
||||
*/
|
||||
@Composable
|
||||
internal fun HiddenCalendarsStep(
|
||||
calendars: List<CalendarSource>,
|
||||
onSetVisible: (id: Long, visible: Boolean) -> Unit,
|
||||
onSwitchAllOn: () -> Unit,
|
||||
onFinished: () -> Unit,
|
||||
writeFailed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val anyVisible = calendars.any { it.isVisibleInSystem }
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
topSpacing = OnboardingSpace.sm,
|
||||
contentPadding = 0.dp,
|
||||
// The list is as long as the device's calendars; pinned actions would
|
||||
// eat the room it needs and hide that there is more below.
|
||||
scrollingActions = true,
|
||||
hero = { IconHero(Icons.Filled.VisibilityOff) },
|
||||
actions = {
|
||||
Button(
|
||||
onClick = if (anyVisible) onFinished else onSwitchAllOn,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = GroupedListInset)
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (anyVisible) {
|
||||
R.string.onboarding_view_continue_button
|
||||
} else {
|
||||
R.string.onboarding_hidden_switch_all_button
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
// Only while nothing is on: once something is, the step has been
|
||||
// answered and the one button left to press says so.
|
||||
if (!anyVisible) {
|
||||
TextButton(
|
||||
onClick = onFinished,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = GroupedListInset)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.onboarding_hidden_skip_button))
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = GroupedListInset)) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_hidden_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_hidden_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (writeFailed) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_hidden_failed),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(OnboardingSpace.md))
|
||||
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
val off = !calendar.isVisibleInSystem
|
||||
GroupedRow(
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
summary = calendar.accountName.takeIf { it.isNotBlank() },
|
||||
position = when {
|
||||
calendars.size == 1 -> Position.Alone
|
||||
index == 0 -> Position.Top
|
||||
index == calendars.lastIndex -> Position.Bottom
|
||||
else -> Position.Middle
|
||||
},
|
||||
dimmed = off,
|
||||
trailing = if (calendar.hasVisibilitySwitch) {
|
||||
{
|
||||
CalendarSwitch(
|
||||
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
enabled = !off,
|
||||
onToggle = { on -> onSetVisible(calendar.id, on) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(OnboardingSpace.md))
|
||||
}
|
||||
}
|
||||
|
||||
/** The per-row switch, writing the device's own `Calendars.VISIBLE` as Settings does. */
|
||||
@Composable
|
||||
private fun CalendarSwitch(
|
||||
calendarName: String,
|
||||
enabled: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = onToggle,
|
||||
modifier = Modifier.semantics { contentDescription = label },
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,19 @@ enum class OnboardingStep {
|
||||
/** Required: nothing works without the calendar grant. */
|
||||
Permission,
|
||||
|
||||
/**
|
||||
* Only when every calendar on the device is switched off, so the app would
|
||||
* open on a blank grid: lists them and offers to switch them back on (#287).
|
||||
*/
|
||||
HiddenCalendars,
|
||||
|
||||
/**
|
||||
* Only when the device has nothing to put an event in — no calendar at all,
|
||||
* or none left showing after the step above. The app can make a local one,
|
||||
* or send you to whatever would do the syncing (#287).
|
||||
*/
|
||||
Calendars,
|
||||
|
||||
/** Whether Calendula delivers reminder notifications itself. */
|
||||
Reminders,
|
||||
|
||||
@@ -67,6 +80,18 @@ data class OnboardingPlan(
|
||||
* otherwise, so the flow can only ever get shorter — never sprout a step the
|
||||
* counter had not accounted for.
|
||||
*
|
||||
* [calendarsApplies] carries the same contract for the calendar step, with one
|
||||
* wrinkle: for the backup step an empty list means "could not read", but here
|
||||
* an empty list *is* the condition. The two are indistinguishable at this
|
||||
* layer and both want the step, so it turns false only once a calendar the app
|
||||
* could actually show has arrived.
|
||||
*
|
||||
* [hiddenApplies] is the step in front of it (#287): the device has calendars
|
||||
* but every one of them is switched off. It comes first because switching one
|
||||
* back on is the lighter remedy — and where it is declined, the calendar step
|
||||
* behind it still offers to make one, the same offer a device with no calendars
|
||||
* at all gets.
|
||||
*
|
||||
* The month-style step is *not* conditional on Month being the chosen view:
|
||||
* Month is reachable from the drawer whatever opens first, and a step that came
|
||||
* and went as the view is picked would move the counter under the user on the
|
||||
@@ -75,7 +100,9 @@ data class OnboardingPlan(
|
||||
* [visibilityArmed] carries the one-time notice that the device is hiding some
|
||||
* calendars (#75) — the only optional step an *existing* install can be given,
|
||||
* which is why it is not gated on `fresh`, and on its own it earns no closing
|
||||
* screen.
|
||||
* screen. It stays a pure announcement: the all-hidden *state* is
|
||||
* [hiddenApplies]'s business, up at the front of the flow where something can
|
||||
* still be done about it.
|
||||
*/
|
||||
fun onboardingPlan(
|
||||
hasPermission: Boolean,
|
||||
@@ -85,6 +112,10 @@ fun onboardingPlan(
|
||||
viewDone: Boolean,
|
||||
monthStyleDone: Boolean,
|
||||
backupApplies: Boolean?,
|
||||
hiddenDone: Boolean = false,
|
||||
hiddenApplies: Boolean? = false,
|
||||
calendarsDone: Boolean = false,
|
||||
calendarsApplies: Boolean? = null,
|
||||
visibilityArmed: Boolean = false,
|
||||
visibilityDone: Boolean = false,
|
||||
doneShown: Boolean = false,
|
||||
@@ -92,6 +123,8 @@ fun onboardingPlan(
|
||||
val fresh = wizardArmed || (!hasPermission && !remindersDone)
|
||||
val steps = buildList {
|
||||
if (!hasPermission || fresh) add(OnboardingStep.Permission)
|
||||
if (fresh && hiddenApplies != false) add(OnboardingStep.HiddenCalendars)
|
||||
if (fresh && calendarsApplies != false) add(OnboardingStep.Calendars)
|
||||
if (!remindersDone || fresh) add(OnboardingStep.Reminders)
|
||||
if (fresh && backupApplies != false) add(OnboardingStep.Backup)
|
||||
if (fresh) {
|
||||
@@ -104,6 +137,8 @@ fun onboardingPlan(
|
||||
val current = steps.firstOrNull { step ->
|
||||
when (step) {
|
||||
OnboardingStep.Permission -> !hasPermission
|
||||
OnboardingStep.HiddenCalendars -> !hiddenDone
|
||||
OnboardingStep.Calendars -> !calendarsDone
|
||||
OnboardingStep.Reminders -> !remindersDone
|
||||
OnboardingStep.Backup -> !backupDone
|
||||
OnboardingStep.View -> !viewDone
|
||||
|
||||
@@ -28,6 +28,9 @@ fun OnboardingSteps(
|
||||
// Collected for every step, not just the two that draw a preview: starting
|
||||
// it when the picker composes would leave that step blank as it slides in.
|
||||
val choice by viewModel.viewChoice.collectAsStateWithLifecycle()
|
||||
val creation by viewModel.creation.collectAsStateWithLifecycle()
|
||||
val hiddenCalendars by viewModel.hiddenCalendars.collectAsStateWithLifecycle()
|
||||
val hiddenWriteFailed by viewModel.hiddenWriteFailed.collectAsStateWithLifecycle()
|
||||
|
||||
// Coerced because the outgoing half of a transition may be a step the live
|
||||
// plan has since dropped — the backup step goes once the calendars say it
|
||||
@@ -67,6 +70,26 @@ fun OnboardingSteps(
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
)
|
||||
OnboardingStep.HiddenCalendars -> HiddenCalendarsStep(
|
||||
calendars = hiddenCalendars,
|
||||
onSetVisible = viewModel::setHiddenCalendarVisible,
|
||||
onSwitchAllOn = viewModel::switchAllCalendarsOn,
|
||||
onFinished = viewModel::finishHiddenCalendars,
|
||||
writeFailed = hiddenWriteFailed,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
)
|
||||
OnboardingStep.Calendars -> CalendarsStep(
|
||||
onCreateLocal = viewModel::createLocalCalendar,
|
||||
onSkip = viewModel::skipCalendars,
|
||||
creation = creation,
|
||||
onFinished = viewModel::finishCalendars,
|
||||
onClearCreation = viewModel::clearCalendarCreation,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
)
|
||||
OnboardingStep.Reminders -> ReminderStep(
|
||||
onFinished = viewModel::finishReminders,
|
||||
modifier = modifier,
|
||||
|
||||
+235
-10
@@ -16,6 +16,8 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
|
||||
import de.jeanlucmakiola.calendula.domain.orderedForManager
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -24,6 +26,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -74,6 +77,27 @@ class OnboardingViewModel @Inject constructor(
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* The calendar list every derived step reads, collected once: one per step
|
||||
* would put a provider query each on every visibility tick, and let the
|
||||
* steps judge different snapshots of the same moment. Null is "no list to
|
||||
* judge" — before the grant, and on a read that failed.
|
||||
*/
|
||||
private val calendars: StateFlow<List<CalendarSource>?> = hasPermission
|
||||
.flatMapLatest { granted ->
|
||||
if (granted != true) {
|
||||
flowOf(null)
|
||||
} else {
|
||||
val list: Flow<List<CalendarSource>?> = repository.calendars()
|
||||
list.catch { emit(null) }.flowOn(io)
|
||||
}
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether the backup step applies: something of yours is worth exporting and
|
||||
* nothing you write to is synced anywhere. Null while the calendar list
|
||||
@@ -81,17 +105,90 @@ class OnboardingViewModel @Inject constructor(
|
||||
* an empty list is also how a null cursor arrives, and guessing false there
|
||||
* would drop the step for the whole session.
|
||||
*/
|
||||
private val backupApplies: Flow<Boolean?> = hasPermission.flatMapLatest { granted ->
|
||||
if (granted != true) {
|
||||
flowOf(null)
|
||||
} else {
|
||||
repository.calendars()
|
||||
.map { calendars -> if (calendars.isEmpty()) null else calendars.backupApplies() }
|
||||
.catch { emit(null) }
|
||||
.flowOn(io)
|
||||
}
|
||||
private val backupApplies: Flow<Boolean?> = calendars.map { list ->
|
||||
if (list.isNullOrEmpty()) null else list.backupApplies()
|
||||
}
|
||||
|
||||
/** How the step's own create attempt ended, while it is still saying so (#287). */
|
||||
private val _creation = MutableStateFlow<CalendarCreation?>(null)
|
||||
val creation: StateFlow<CalendarCreation?> = _creation.asStateFlow()
|
||||
|
||||
/**
|
||||
* Whether the step made a calendar here. Latched for the session and never
|
||||
* cleared: the new calendar answers [calendarsApplies] with false, which
|
||||
* would drop the step out of the plan the moment it is used — first from
|
||||
* under the message that it worked, and then from the step count, which
|
||||
* would fall by one just as the user pressed Continue.
|
||||
*/
|
||||
private val calendarCreatedHere = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* Whether the calendar step applies: the device has nothing to show and so
|
||||
* nothing to put events in. Unlike [backupApplies] an empty list is the
|
||||
* condition itself, not a failed read — the two look identical here and both
|
||||
* want the step, so this only resolves once a calendar the app can actually
|
||||
* show has arrived. Calendars that exist but are all switched off leave it
|
||||
* unresolved too: the step in front (#287) offers to switch them on, and
|
||||
* declining that lands here, on the same offer a device with none gets.
|
||||
*/
|
||||
private val calendarsApplies: Flow<Boolean?> = combine(
|
||||
calendars.map { list -> if (list?.any { it.isVisibleInSystem } == true) false else null },
|
||||
calendarCreatedHere,
|
||||
) { applies, latched -> if (latched) null else applies }
|
||||
|
||||
/**
|
||||
* Set when the step starts a write of its own, and never cleared: from that
|
||||
* moment the step holds its place in the plan until it is answered. Set
|
||||
* *before* the write rather than after it, because a successful one answers
|
||||
* [hiddenApplies] with false — the step would drop out between the provider
|
||||
* taking the change and the latch seeing it.
|
||||
*/
|
||||
private val hiddenTouched = MutableStateFlow(false)
|
||||
|
||||
/** A visibility write the provider did not take, while the step says so (#287). */
|
||||
private val _hiddenWriteFailed = MutableStateFlow(false)
|
||||
val hiddenWriteFailed: StateFlow<Boolean> = _hiddenWriteFailed.asStateFlow()
|
||||
|
||||
/**
|
||||
* Whether the device is showing nothing at all right now: calendars exist,
|
||||
* none is visible, and at least one of them has a switch the step can flip
|
||||
* (#287). Null only while there is no list to judge — before the grant, and
|
||||
* on a read that failed — which keeps the step in the plan until the answer
|
||||
* arrives, the same shrink-only contract [backupApplies] keeps.
|
||||
*
|
||||
* An empty list answers false rather than null, unlike [calendarsApplies]:
|
||||
* a phone with no calendars at all is the next step's business, and this one
|
||||
* would be listing calendars it cannot see. [hiddenTouched] holds it at null
|
||||
* from the step's first write onwards.
|
||||
*/
|
||||
private val hiddenApplies: Flow<Boolean?> = combine(
|
||||
calendars.map { list -> list?.allHidden() },
|
||||
hiddenTouched,
|
||||
) { hidden, touched -> if (touched) null else hidden }
|
||||
|
||||
/** Both calendar steps' inputs: what each applies to, and whether it is answered. */
|
||||
private val calendarsFlags: Flow<CalendarsFlags> = combine(
|
||||
calendarsApplies,
|
||||
prefs.onboardingCalendarsDone,
|
||||
hiddenApplies,
|
||||
prefs.onboardingHiddenCalendarsDone,
|
||||
::CalendarsFlags,
|
||||
)
|
||||
|
||||
/**
|
||||
* The calendars the hidden-calendars step lists, so the user can judge which
|
||||
* to switch on rather than take the app's word that there are some. Live: a
|
||||
* row follows its own switch, and the ones without one stay listed anyway —
|
||||
* being told a calendar exists is half the point.
|
||||
*/
|
||||
val hiddenCalendars: StateFlow<List<CalendarSource>> = calendars
|
||||
.map { it.orEmpty().orderedForManager() }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
private val flags: Flow<OnboardingFlags> = combine(
|
||||
prefs.reminderOnboardingDone,
|
||||
prefs.onboardingWizardArmed,
|
||||
@@ -110,7 +207,13 @@ class OnboardingViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
val plan: StateFlow<OnboardingPlan?> =
|
||||
combine(hasPermission, flags, closingFlags, backupApplies) { granted, stored, closing, backup ->
|
||||
combine(
|
||||
hasPermission,
|
||||
flags,
|
||||
closingFlags,
|
||||
backupApplies,
|
||||
calendarsFlags,
|
||||
) { granted, stored, closing, backup, calendars ->
|
||||
granted?.let {
|
||||
onboardingPlan(
|
||||
hasPermission = it,
|
||||
@@ -120,6 +223,10 @@ class OnboardingViewModel @Inject constructor(
|
||||
viewDone = stored.viewDone,
|
||||
monthStyleDone = stored.monthStyleDone,
|
||||
backupApplies = backup,
|
||||
hiddenDone = calendars.hiddenDone,
|
||||
hiddenApplies = calendars.hiddenApplies,
|
||||
calendarsDone = calendars.done,
|
||||
calendarsApplies = calendars.applies,
|
||||
visibilityArmed = closing.visibilityArmed,
|
||||
visibilityDone = closing.visibilityDone,
|
||||
doneShown = closing.doneShown,
|
||||
@@ -186,6 +293,8 @@ class OnboardingViewModel @Inject constructor(
|
||||
fun goBack() {
|
||||
viewModelScope.launch {
|
||||
when (plan.value?.previous) {
|
||||
OnboardingStep.HiddenCalendars -> prefs.setOnboardingHiddenCalendarsDone(false)
|
||||
OnboardingStep.Calendars -> prefs.setOnboardingCalendarsDone(false)
|
||||
OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false)
|
||||
OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false)
|
||||
OnboardingStep.View -> prefs.setOnboardingViewDone(false)
|
||||
@@ -202,6 +311,53 @@ class OnboardingViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setOnboardingBackupDone() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the device-only calendar the step's editor just described. The step
|
||||
* stays up on [creation] either way — to confirm the calendar and say where
|
||||
* to make more, or to say the provider refused it. Answering the step for
|
||||
* them would leave them where the step exists to stop them being: past it,
|
||||
* with nowhere to put an event.
|
||||
*/
|
||||
fun createLocalCalendar(displayName: String, color: Int, description: String?) {
|
||||
viewModelScope.launch {
|
||||
val name = displayName.trim()
|
||||
_creation.value = runCatching {
|
||||
repository.createLocalCalendar(
|
||||
displayName = name,
|
||||
color = color,
|
||||
description = description,
|
||||
)
|
||||
}.fold(
|
||||
onSuccess = {
|
||||
calendarCreatedHere.value = true
|
||||
CalendarCreation.Created(name)
|
||||
},
|
||||
onFailure = { CalendarCreation.Failed },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the calendar step once its confirmation has been read. */
|
||||
fun finishCalendars() {
|
||||
viewModelScope.launch {
|
||||
prefs.setOnboardingCalendarsDone()
|
||||
_creation.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the failed attempt so the step offers itself again. */
|
||||
fun clearCalendarCreation() {
|
||||
_creation.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the calendar step without making one. An empty app is a legitimate
|
||||
* choice, and so is going off to set up an account and coming back.
|
||||
*/
|
||||
fun skipCalendars() {
|
||||
viewModelScope.launch { prefs.setOnboardingCalendarsDone() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn automatic backup on, writing to the folder the user just picked
|
||||
* (taking a durable write grant so background runs can keep writing), and
|
||||
@@ -246,6 +402,52 @@ class OnboardingViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setOnboardingMonthStyleDone() }
|
||||
}
|
||||
|
||||
/** Switch one listed calendar on or off, from the hidden-calendars step (#287). */
|
||||
fun setHiddenCalendarVisible(id: Long, visible: Boolean) = visibilityWrite {
|
||||
repository.setCalendarsVisible(listOf(id), visible)
|
||||
calendarList().any { it.id == id && it.isVisibleInSystem == visible }
|
||||
}
|
||||
|
||||
/** Switch every hidden calendar back on — the step's one-tap way out (#287). */
|
||||
fun switchAllCalendarsOn() = visibilityWrite {
|
||||
val hidden = calendarList().filter { it.hasVisibilitySwitch && !it.isVisibleInSystem }
|
||||
// Nothing to write is not a write that landed: a list that came back
|
||||
// empty (a failed read) would otherwise report success and leave the
|
||||
// step sitting there with no rows and no explanation.
|
||||
if (hidden.isEmpty()) return@visibilityWrite false
|
||||
repository.setCalendarsVisible(hidden.map { it.id }, true)
|
||||
calendarList().any { it.isVisibleInSystem }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one of the step's visibility writes, whose lambda answers whether it
|
||||
* took. Checked rather than assumed: with read-only calendar access the
|
||||
* choice is kept app-side (#75) and nothing the grid shows would change.
|
||||
*/
|
||||
private fun visibilityWrite(landed: suspend () -> Boolean) {
|
||||
viewModelScope.launch {
|
||||
hiddenTouched.value = true
|
||||
_hiddenWriteFailed.value = !runCatching { landed() }.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun calendarList(): List<CalendarSource> = repository.calendars().first()
|
||||
|
||||
/** Close the hidden-calendars step, switched on or left as they were (#287). */
|
||||
fun finishHiddenCalendars() {
|
||||
viewModelScope.launch {
|
||||
prefs.setOnboardingHiddenCalendarsDone()
|
||||
// The error belongs to the attempt, not to the step: stepping back
|
||||
// into it later must not find a refusal nothing has asked for yet.
|
||||
_hiddenWriteFailed.value = false
|
||||
val list = calendarList()
|
||||
// Switching them all back on answers the one-time notice too (#75) —
|
||||
// it would otherwise announce, at the end of this same flow, that the
|
||||
// device is hiding calendars it no longer hides.
|
||||
if (list.isNotEmpty() && list.all { it.isVisibleInSystem }) retireVisibilityNotice()
|
||||
}
|
||||
}
|
||||
|
||||
/** Acknowledge the calendar-visibility notice (#75). */
|
||||
fun finishVisibility() {
|
||||
viewModelScope.launch {
|
||||
@@ -295,6 +497,20 @@ private data class OnboardingFlags(
|
||||
val monthStyleDone: Boolean,
|
||||
)
|
||||
|
||||
/** How the calendar step's create attempt ended (#287). */
|
||||
sealed interface CalendarCreation {
|
||||
data class Created(val name: String) : CalendarCreation
|
||||
data object Failed : CalendarCreation
|
||||
}
|
||||
|
||||
/** Both calendar steps' inputs (#287). */
|
||||
private data class CalendarsFlags(
|
||||
val applies: Boolean?,
|
||||
val done: Boolean,
|
||||
val hiddenApplies: Boolean?,
|
||||
val hiddenDone: Boolean,
|
||||
)
|
||||
|
||||
/** The tail of the flow: the visibility notice and the closing screen. */
|
||||
private data class ClosingFlags(
|
||||
val visibilityArmed: Boolean,
|
||||
@@ -309,6 +525,15 @@ data class ViewChoice(
|
||||
val weekStart: WeekStartPref,
|
||||
)
|
||||
|
||||
/**
|
||||
* True when this device would show nothing and the step could change that: no
|
||||
* calendar is visible, and at least one of them has a switch worth flipping —
|
||||
* a non-syncing calendar holds no events on the device, so switching it on
|
||||
* leaves the grid exactly as empty (#287).
|
||||
*/
|
||||
private fun List<CalendarSource>.allHidden(): Boolean =
|
||||
any { it.hasVisibilitySwitch } && none { it.isVisibleInSystem }
|
||||
|
||||
/**
|
||||
* True when this device holds events that live nowhere else: at least one local
|
||||
* calendar worth exporting, and no writable synced calendar to carry them.
|
||||
|
||||
@@ -509,6 +509,9 @@ private fun SearchResultRow(
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val event = hit.event
|
||||
// Spans are empty for a blank title — the query never matches the
|
||||
// placeholder — so marking the substitute is safe.
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
// On a picked row the headline is already recoloured for the secondary
|
||||
@@ -521,7 +524,7 @@ private fun SearchResultRow(
|
||||
// Faded like a past event anywhere else in the app — search reaches back
|
||||
// through the whole history.
|
||||
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = declinedTitle(marked(event.title, hit.titleSpans, highlight), event.isDeclined),
|
||||
title = declinedTitle(marked(title, hit.titleSpans, highlight), event.isDeclined),
|
||||
summary = searchSummary(hit, highlight),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
|
||||
@@ -143,7 +143,7 @@ internal fun EventFormScreen(
|
||||
durationCalendars.forEachIndexed { index, calendar ->
|
||||
val override = state.perCalendarEventDuration[calendar.id]
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
summary = override?.let { durationLabel(it) }
|
||||
?: stringResource(
|
||||
R.string.settings_calendar_duration_inherits,
|
||||
|
||||
@@ -181,7 +181,7 @@ internal fun NotificationsScreen(
|
||||
// own section — link there instead.
|
||||
if (calendar.id in state.managedCalendarIds) {
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
||||
position = Position.Alone,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
@@ -198,7 +198,7 @@ internal fun NotificationsScreen(
|
||||
}
|
||||
val expanded = calendar.id in expandedCalendars
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
position = if (expanded) Position.Top else Position.Alone,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.plus
|
||||
|
||||
/** Pages the week pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */
|
||||
internal const val WEEK_PAGE_COUNT: Int = 20_000
|
||||
|
||||
private const val EPOCH_PAGE = WEEK_PAGE_COUNT / 2
|
||||
|
||||
/** The week at [EPOCH_PAGE]; any date works, it only has to stay fixed. */
|
||||
private val PAGE_EPOCH = LocalDate(2000, 1, 3)
|
||||
|
||||
/** First day of the week shown on pager [page], with weeks starting on [firstDay]. */
|
||||
internal fun weekStartForPage(page: Int, firstDay: DayOfWeek): LocalDate =
|
||||
PAGE_EPOCH.startOfWeek(firstDay).plus((page - EPOCH_PAGE) * 7, DateTimeUnit.DAY)
|
||||
|
||||
/** The pager page whose week contains [date], with weeks starting on [firstDay]. */
|
||||
internal fun weekPageFor(date: LocalDate, firstDay: DayOfWeek): Int {
|
||||
val days = PAGE_EPOCH.startOfWeek(firstDay).daysUntil(date.startOfWeek(firstDay))
|
||||
return (EPOCH_PAGE + days / 7).coerceIn(0, WEEK_PAGE_COUNT - 1)
|
||||
}
|
||||
@@ -1,7 +1,21 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerDefaults
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
|
||||
import de.jeanlucmakiola.calendula.ui.common.pagedStripHeight
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
@@ -48,12 +62,10 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
@@ -69,6 +81,7 @@ import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -122,9 +135,7 @@ import de.jeanlucmakiola.calendula.ui.common.trimmedLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
||||
import de.jeanlucmakiola.calendula.ui.common.withTitleWeight
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
@@ -142,7 +153,6 @@ import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
|
||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -152,7 +162,6 @@ import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
@@ -162,6 +171,14 @@ private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||
/** Gap between day columns; part of the column pitch a drag maps positions through. */
|
||||
private val COLUMN_GAP = 2.dp
|
||||
|
||||
/** Breathing room between the header block and the scrolling timeline below. */
|
||||
private val TIMELINE_TOP_GAP = 8.dp
|
||||
private val HEADER_TOP_PADDING = 4.dp
|
||||
private val HEADER_BOTTOM_PADDING = 8.dp
|
||||
|
||||
/** The header's date slot, reserved whether or not it holds today's circle. */
|
||||
private val DATE_SLOT_SIZE = 28.dp
|
||||
|
||||
/** Total all-day strip height for a week (0 when there are no all-day events). */
|
||||
internal fun WeekUiState.Success.allDayStripHeight(): Dp {
|
||||
if (allDaySpans.isEmpty()) return 0.dp
|
||||
@@ -186,7 +203,8 @@ fun WeekScreen(
|
||||
viewModel: WeekViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle()
|
||||
val firstDay by viewModel.firstDayOfWeek.collectAsStateWithLifecycle()
|
||||
val anchorPage by viewModel.anchorPage.collectAsStateWithLifecycle()
|
||||
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||
// The instant before which an event counts as completed, or null when dimming
|
||||
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||
@@ -199,39 +217,41 @@ fun WeekScreen(
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val isOnCurrentWeek = when (val s = state) {
|
||||
// True when today falls inside the displayed week — independent of which
|
||||
// weekday the user picked as the first day.
|
||||
is WeekUiState.Success ->
|
||||
s.today >= s.weekStart && s.today <= s.weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
||||
else -> true
|
||||
}
|
||||
|
||||
// Drives whether the title carries the year. Falls back to the clock only
|
||||
// while the first load is in flight, when there is no state to read today from.
|
||||
val currentYear = when (val s = state) {
|
||||
is WeekUiState.Success -> s.today.year
|
||||
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
||||
}
|
||||
|
||||
// Slide direction for the week transition: +1 = next, -1 = prev, 0 = jump.
|
||||
var slideDir by remember { mutableIntStateOf(0) }
|
||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||
val goPrev = { slideDir = -1; viewModel.goToPrev() }
|
||||
// Slide toward today: viewing the future → today comes in from the left
|
||||
// (back), viewing the past → from the right (forward).
|
||||
val jumpToToday = {
|
||||
slideDir = when (val s = state) {
|
||||
is WeekUiState.Success -> if (s.today < s.weekStart) -1 else 1
|
||||
else -> 0
|
||||
val pagerState = rememberPagerState(initialPage = anchorPage) { WEEK_PAGE_COUNT }
|
||||
val pageSpec = rememberCalendarPageSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
// The pager leads and the anchor follows once it settles, so the only thing
|
||||
// left moving the anchor on its own is the first day re-framing the week —
|
||||
// including the stored preference arriving after the pager opened on the
|
||||
// Monday default. Snapped, since that is a correction rather than a move.
|
||||
LaunchedEffect(anchorPage) {
|
||||
if (pagerState.currentPage != anchorPage && !pagerState.isScrollInProgress) {
|
||||
pagerState.scrollToPage(anchorPage)
|
||||
}
|
||||
viewModel.goToToday()
|
||||
}
|
||||
// Drawer jump-to-date: slide from the side the target week lies on.
|
||||
// The page it opened on is skipped: a first-day change landing in between
|
||||
// would read it as a different week.
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled)
|
||||
}
|
||||
// The week under the finger, so the title turns over mid-swipe, not after it.
|
||||
val weekStart = weekStartForPage(pagerState.currentPage, firstDay)
|
||||
|
||||
// Off the ticking clock rather than the loaded week, so it turns over at
|
||||
// midnight; derived, so the per-minute tick doesn't recompose the screen.
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } }
|
||||
// Independent of which weekday the user picked as the first day.
|
||||
val isOnCurrentWeek = state !is WeekUiState.Success ||
|
||||
(today >= weekStart && today <= weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY))
|
||||
|
||||
// Straight to the pager: a tap mid-fling must still land, and the anchor
|
||||
// hasn't caught up with the fling yet to tell the difference.
|
||||
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||
slideDir = if (target < weekStart) -1 else 1
|
||||
viewModel.goToDate(target)
|
||||
val page = weekPageFor(target, firstDay)
|
||||
scope.launch { pagerState.jumpToPage(page, reduceMotion, pageSpec) }
|
||||
}
|
||||
val jumpToToday = { jumpToDate(today) }
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -263,7 +283,7 @@ fun WeekScreen(
|
||||
topBar = {
|
||||
WeekTopBar(
|
||||
weekStart = weekStart,
|
||||
currentYear = currentYear,
|
||||
currentYear = today.year,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
@@ -281,8 +301,6 @@ fun WeekScreen(
|
||||
onToday = jumpToToday,
|
||||
onCreate = {
|
||||
// Anchor on today when it's in view, else the week's first day.
|
||||
val today = Clock.System.now()
|
||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
onCreateEvent(if (isOnCurrentWeek) today else weekStart, null)
|
||||
},
|
||||
)
|
||||
@@ -291,10 +309,10 @@ fun WeekScreen(
|
||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||
WeekContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
pagerState = pagerState,
|
||||
firstDay = firstDay,
|
||||
week = viewModel::week,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||
@@ -310,20 +328,18 @@ fun WeekScreen(
|
||||
@Composable
|
||||
private fun WeekContent(
|
||||
state: WeekUiState,
|
||||
slideDir: Int,
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
pagerState: PagerState,
|
||||
firstDay: DayOfWeek,
|
||||
week: (LocalDate) -> StateFlow<WeekUiState>,
|
||||
onRetry: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
|
||||
// Hoisted above the per-week AnimatedContent so the vertical scroll position
|
||||
// Shared by every page and the gutter, so the vertical scroll position
|
||||
// survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on
|
||||
// noon once, on first entry into the week view (i.e. when arriving from the
|
||||
// month/day view), not on every swipe.
|
||||
@@ -335,62 +351,47 @@ private fun WeekContent(
|
||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||
}
|
||||
|
||||
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
||||
// week during a swipe, so the strip slides along but never jumps in height;
|
||||
// it just springs smoothly from the old to the new size.
|
||||
val targetAllDayHeight = (state as? WeekUiState.Success)?.allDayStripHeight() ?: 0.dp
|
||||
val allDayHeight by animateDpAsState(
|
||||
targetValue = targetAllDayHeight,
|
||||
label = "all-day-strip-height",
|
||||
)
|
||||
|
||||
// Whole-page horizontal swipe. It sits one level above the timeline's
|
||||
// vertical scroll: a horizontal drag only crosses *this* detector's slop,
|
||||
// while a vertical drag is consumed by the inner scroll first — so the two
|
||||
// gestures coexist without fighting.
|
||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||
|
||||
// Above the AnimatedContent: a page change mid-drag would strand the
|
||||
// floating block inside the outgoing page.
|
||||
// Above the pager: a page change mid-drag would strand the floating block
|
||||
// inside the outgoing page.
|
||||
val dragController = rememberTimelineDragController()
|
||||
val move = LocalEventMove.current
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val onDrop: (TimelineDrop) -> Unit = { drop ->
|
||||
move?.move(
|
||||
MoveRequest(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
// Loading and failure concern the provider as a whole, so they stand in
|
||||
// for the pager; a page still loading its own week handles that itself.
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
when (s) {
|
||||
is WeekUiState.Success -> "success-${s.weekStart}"
|
||||
is WeekUiState.Failure -> "failure-${s.reason}"
|
||||
WeekUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "week-transition",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentKey = { it::class },
|
||||
transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) },
|
||||
label = "week-state",
|
||||
) { s ->
|
||||
when (s) {
|
||||
WeekUiState.Loading -> WeekLoading()
|
||||
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is WeekUiState.Success -> WeekSuccess(
|
||||
state = s,
|
||||
is WeekUiState.Success -> WeekPager(
|
||||
pagerState = pagerState,
|
||||
firstDay = firstDay,
|
||||
week = week,
|
||||
today = s.today,
|
||||
initialStripHeight = s.allDayStripHeight(),
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = { drop ->
|
||||
move?.move(
|
||||
MoveRequest(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||
),
|
||||
)
|
||||
},
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -398,6 +399,123 @@ private fun WeekContent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The weeks as pages that follow the finger and snap once a swipe passes half a
|
||||
* page or is flung (#131), beside a gutter that stays put.
|
||||
*/
|
||||
@Composable
|
||||
private fun WeekPager(
|
||||
pagerState: PagerState,
|
||||
firstDay: DayOfWeek,
|
||||
week: (LocalDate) -> StateFlow<WeekUiState>,
|
||||
today: LocalDate,
|
||||
initialStripHeight: Dp,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
|
||||
val allDayHeight = pagedStripHeight(pagerState, stripHeights, initialStripHeight)
|
||||
WeekFrame(
|
||||
weekStart = weekStartForPage(pagerState.currentPage, firstDay),
|
||||
allDayHeight = allDayHeight,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
// The weeks either side are composed ahead, so a swipe lands on a
|
||||
// loaded week rather than watching it fill in.
|
||||
beyondViewportPageCount = 1,
|
||||
// A held block moves within its week; the page stays put under it.
|
||||
userScrollEnabled = !dragController.isDragging,
|
||||
flingBehavior = PagerDefaults.flingBehavior(
|
||||
state = pagerState,
|
||||
snapAnimationSpec = rememberCalendarPageSpec(),
|
||||
),
|
||||
) { page ->
|
||||
val start = weekStartForPage(page, firstDay)
|
||||
val pageState by remember(start) { week(start) }.collectAsStateWithLifecycle()
|
||||
val loaded = pageState as? WeekUiState.Success
|
||||
// Until its week arrives a page shows the week's frame without events.
|
||||
val empty = remember(start, today) { emptyWeek(start, today) }
|
||||
val stripHeight = loaded?.allDayStripHeight()
|
||||
DisposableEffect(page, stripHeight) {
|
||||
if (stripHeight != null) stripHeights[page] = stripHeight
|
||||
onDispose { stripHeights.remove(page) }
|
||||
}
|
||||
WeekPage(
|
||||
state = loaded ?: empty,
|
||||
allDayHeight = allDayHeight,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
active = page == pagerState.currentPage,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A week with no events yet, for a page whose week is still loading. */
|
||||
private fun emptyWeek(start: LocalDate, today: LocalDate): WeekUiState.Success {
|
||||
val days = (0 until 7).map { start.plus(it, kotlinx.datetime.DateTimeUnit.DAY) }
|
||||
return WeekUiState.Success(
|
||||
weekStart = start,
|
||||
today = today,
|
||||
days = days,
|
||||
allDaySpans = emptyList(),
|
||||
timedByDay = emptyMap(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What stays put while the weeks swipe: the week-number badge and the hour
|
||||
* gutter, with [pages] filling the rest.
|
||||
*/
|
||||
@Composable
|
||||
private fun WeekFrame(
|
||||
weekStart: LocalDate,
|
||||
allDayHeight: Dp,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
pages: @Composable () -> Unit,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.width(GUTTER_WIDTH).fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
WeekNumberCell(weekStart)
|
||||
Spacer(Modifier.height(allDayHeight))
|
||||
}
|
||||
Spacer(Modifier.height(TIMELINE_TOP_GAP))
|
||||
// Resolves the hour height off the same viewport height the pages'
|
||||
// timelines have, so labels and columns agree.
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val zoom = LocalTimelineZoom.current
|
||||
val hourHeight = zoom.scale.hourHeight(maxHeight)
|
||||
HourGutter(
|
||||
scrollState = scrollState,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
modifier = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom),
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f).fillMaxHeight()) { pages() }
|
||||
}
|
||||
}
|
||||
|
||||
/** A single week in its frame, without the pager — for the Settings preview. */
|
||||
@Composable
|
||||
internal fun WeekSuccess(
|
||||
state: WeekUiState.Success,
|
||||
@@ -408,6 +526,44 @@ internal fun WeekSuccess(
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
WeekFrame(
|
||||
weekStart = state.weekStart,
|
||||
allDayHeight = allDayHeight,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
) {
|
||||
WeekPage(
|
||||
state = state,
|
||||
allDayHeight = allDayHeight,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
active = true,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One week's page: header, all-day strip and day columns.
|
||||
*
|
||||
* @param active whether this is the page on screen, the only one that may
|
||||
* publish the drag geometry.
|
||||
*/
|
||||
@Composable
|
||||
private fun WeekPage(
|
||||
state: WeekUiState.Success,
|
||||
allDayHeight: Dp,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
active: Boolean,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
@@ -424,13 +580,12 @@ internal fun WeekSuccess(
|
||||
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
|
||||
}
|
||||
}
|
||||
// Breathing room between the top section and the scrolling timeline
|
||||
// below.
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Spacer(Modifier.height(TIMELINE_TOP_GAP))
|
||||
Timeline(
|
||||
state = state,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
active = active,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
@@ -505,24 +660,11 @@ private fun WeekDayHeader(
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
val weekStart = days.first()
|
||||
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp, bottom = 8.dp, end = TIMELINE_CONTENT_END_INSET),
|
||||
.padding(top = HEADER_TOP_PADDING, bottom = HEADER_BOTTOM_PADDING, end = TIMELINE_CONTENT_END_INSET),
|
||||
) {
|
||||
// Mirror the day-column layout (empty weekday line + spacer) so the
|
||||
// badge lines up vertically with the date numbers. The start inset centres
|
||||
// the badge on the top bar's hamburger (see GUTTER_CONTENT_START_INSET).
|
||||
Column(
|
||||
modifier = Modifier.width(GUTTER_WIDTH).padding(start = GUTTER_CONTENT_START_INSET),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(text = " ", style = MaterialTheme.typography.labelSmall)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
WeekNumberBadge(weekNumber = weekNumber)
|
||||
}
|
||||
days.forEach { date ->
|
||||
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
|
||||
val isToday = date == today
|
||||
@@ -533,16 +675,19 @@ private fun WeekDayHeader(
|
||||
.clickable { onOpenDay(date) },
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// One line, as the gutter cell beside it assumes (see WeekNumberCell).
|
||||
Text(
|
||||
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Clip,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
// Always reserve the 28dp circle slot so the header height is
|
||||
// Always reserve the circle slot so the header height is
|
||||
// identical whether or not the week contains today.
|
||||
Box(
|
||||
modifier = Modifier.size(28.dp),
|
||||
modifier = Modifier.size(DATE_SLOT_SIZE),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isToday) {
|
||||
@@ -572,6 +717,33 @@ private fun WeekDayHeader(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The header's gutter cell, laid out like a day's (empty weekday line, spacer,
|
||||
* date slot) so the badge lines up with the date numbers and the cell is exactly
|
||||
* as tall as the header beside it. The start inset centres the badge on the top
|
||||
* bar's hamburger (see GUTTER_CONTENT_START_INSET).
|
||||
*/
|
||||
@Composable
|
||||
private fun WeekNumberCell(weekStart: LocalDate) {
|
||||
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = GUTTER_CONTENT_START_INSET,
|
||||
top = HEADER_TOP_PADDING,
|
||||
bottom = HEADER_BOTTOM_PADDING,
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(text = " ", style = MaterialTheme.typography.labelSmall)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Box(modifier = Modifier.height(DATE_SLOT_SIZE), contentAlignment = Alignment.TopCenter) {
|
||||
WeekNumberBadge(weekNumber = weekNumber)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Calendar-week badge shown in the header gutter, deliberately set apart with a
|
||||
* filled box and bold number — at the month grid's size, so the two agree (#213). */
|
||||
@Composable
|
||||
@@ -612,8 +784,6 @@ private fun AllDayStrip(
|
||||
end = TIMELINE_CONTENT_END_INSET,
|
||||
),
|
||||
) {
|
||||
// Keep the gutter-width offset so the bars line up with the day columns.
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
// Span bars are positioned absolutely so a multi-day event is one
|
||||
// connected bar across columns rather than a chip per day. clipToBounds
|
||||
// keeps bars from spilling out while the height animates.
|
||||
@@ -682,6 +852,7 @@ private fun Timeline(
|
||||
state: WeekUiState.Success,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
active: Boolean,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
@@ -703,70 +874,61 @@ private fun Timeline(
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val hourHeight = zoom.scale.hourHeight(maxHeight)
|
||||
val totalHeight = hourHeight * 24
|
||||
// The pinch sits on the Row, above both scroll viewports: it has to
|
||||
// outrank the vertical scroll, and it does that by watching the initial
|
||||
// pass, which only reaches it if it is their ancestor.
|
||||
// The pinch sits above the scroll viewport: it has to outrank the
|
||||
// vertical scroll, and it does that by watching the initial pass, which
|
||||
// only reaches it if it is the viewport's ancestor.
|
||||
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
|
||||
// Gutter and day columns are two scroll viewports that SHARE one scroll
|
||||
// state, so they stay perfectly aligned. The day-column viewport is a
|
||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||
// soft corners are permanent at any scroll position (not just at the
|
||||
// day's start/end).
|
||||
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
||||
// Hour gutter (scrolls in sync with the day columns). Same start inset
|
||||
// as the header badge so the labels sit under it and on the hamburger.
|
||||
HourGutter(
|
||||
scrollState = scrollState,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
)
|
||||
// Day columns: rounded, clipped scroll viewport (permanent corners).
|
||||
Box(
|
||||
// Scrolls on the same state as the gutter and the other pages, so they
|
||||
// all stay aligned. A static, rounded-clipped window — the content
|
||||
// scrolls inside it, so the soft corners are permanent at any scroll
|
||||
// position (not just at the day's start/end).
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(pinch)
|
||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.verticalScroll(scrollState)
|
||||
.onGloballyPositioned { if (active) dragController.geometry.viewport = it },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.verticalScroll(scrollState)
|
||||
.onGloballyPositioned { dragController.geometry.viewport = it },
|
||||
.fillMaxWidth()
|
||||
.height(totalHeight)
|
||||
// The scrolling content itself, so its root position
|
||||
// already folds in the scroll offset. Only the page on
|
||||
// screen publishes it: the ones either side are laid out too.
|
||||
.onGloballyPositioned { coords ->
|
||||
if (!active) return@onGloballyPositioned
|
||||
val gap = with(density) { COLUMN_GAP.toPx() }
|
||||
dragController.geometry.let {
|
||||
it.grid = coords
|
||||
it.scroll = scrollState
|
||||
it.hourPx = with(density) { hourHeight.toPx() }
|
||||
it.blockInsetPx = blockInsetPx
|
||||
it.columnGapPx = gap
|
||||
it.columnWidthPx = (coords.size.width + gap) / state.days.size
|
||||
it.days = state.days
|
||||
it.isRtl = isRtl
|
||||
}
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(totalHeight)
|
||||
// The scrolling content itself, so its root position
|
||||
// already folds in the scroll offset.
|
||||
.onGloballyPositioned { coords ->
|
||||
val gap = with(density) { COLUMN_GAP.toPx() }
|
||||
dragController.geometry.let {
|
||||
it.grid = coords
|
||||
it.scroll = scrollState
|
||||
it.hourPx = with(density) { hourHeight.toPx() }
|
||||
it.blockInsetPx = blockInsetPx
|
||||
it.columnGapPx = gap
|
||||
it.columnWidthPx = (coords.size.width + gap) / state.days.size
|
||||
it.days = state.days
|
||||
it.isRtl = isRtl
|
||||
}
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP),
|
||||
) {
|
||||
state.days.forEach { day ->
|
||||
DayColumnCard(
|
||||
blocks = state.timedByDay[day].orEmpty(),
|
||||
dark = dark,
|
||||
date = day,
|
||||
today = state.today,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
state.days.forEach { day ->
|
||||
DayColumnCard(
|
||||
blocks = state.timedByDay[day].orEmpty(),
|
||||
dark = dark,
|
||||
date = day,
|
||||
today = state.today,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNot
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -30,6 +32,7 @@ import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
@@ -38,9 +41,13 @@ import java.util.Locale
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.abs
|
||||
|
||||
const val MINUTES_PER_DAY: Int = 24 * 60
|
||||
|
||||
/** How far from the requested week [WeekViewModel.week] keeps other weeks cached. */
|
||||
private const val WEEK_CACHE_DAYS = 28
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class WeekViewModel @Inject constructor(
|
||||
@@ -64,7 +71,7 @@ class WeekViewModel @Inject constructor(
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
/** First day of the week, from the Settings preference (AUTO → locale). */
|
||||
private val weekStart: StateFlow<DayOfWeek> = settingsPrefs.weekStart
|
||||
val firstDayOfWeek: StateFlow<DayOfWeek> = settingsPrefs.weekStart
|
||||
.map { it.resolveFirstDay(locale) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
@@ -73,55 +80,65 @@ class WeekViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
// Anchor is a representative day inside the visible week; the actual week
|
||||
// start is derived against [weekStart], so changing the first-day preference
|
||||
// re-frames the same week instead of jumping.
|
||||
// start is derived against [firstDayOfWeek], so changing the first-day
|
||||
// preference re-frames the same week instead of jumping.
|
||||
private val _anchor = MutableStateFlow(todayDate)
|
||||
|
||||
val weekStartDate: StateFlow<LocalDate> =
|
||||
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
|
||||
/** The pager page the anchor's week sits on. */
|
||||
val anchorPage: StateFlow<Int> =
|
||||
combine(_anchor, firstDayOfWeek) { anchor, ws -> weekPageFor(anchor, ws) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = todayDate.startOfWeek(DayOfWeek.MONDAY),
|
||||
initialValue = weekPageFor(todayDate, DayOfWeek.MONDAY),
|
||||
)
|
||||
|
||||
/**
|
||||
* The anchor week's state. Once loaded it never falls back to [WeekUiState.Loading]:
|
||||
* moving the anchor keeps the last result until the new week arrives, since
|
||||
* the pages draw their own weeks and this only gates the failure screen.
|
||||
*/
|
||||
val state: StateFlow<WeekUiState> =
|
||||
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
|
||||
combine(_anchor, firstDayOfWeek) { anchor, ws -> anchor.startOfWeek(ws) }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { start ->
|
||||
val range = weekRange(start, zone)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
) { calendars, instances ->
|
||||
buildState(start, calendars, instances)
|
||||
}
|
||||
}
|
||||
.flatMapLatest { start -> week(start).filterNot { it is WeekUiState.Loading } }
|
||||
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = WeekUiState.Loading,
|
||||
)
|
||||
|
||||
fun goToPrev() {
|
||||
_anchor.value = _anchor.value.minus(7, DateTimeUnit.DAY)
|
||||
private val weeks = PageStateCache<LocalDate, WeekUiState>(
|
||||
scope = viewModelScope,
|
||||
initial = WeekUiState.Loading,
|
||||
// Far enough to cover the pages either side and a swipe back, so paging
|
||||
// through a year doesn't keep a year of queries around.
|
||||
keep = { cached, requested -> abs(cached.daysUntil(requested)) <= WEEK_CACHE_DAYS },
|
||||
) { start ->
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(weekRange(start, zone)),
|
||||
) { calendars, instances ->
|
||||
buildState(start, calendars, instances)
|
||||
}
|
||||
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
}
|
||||
|
||||
fun goToNext() {
|
||||
_anchor.value = _anchor.value.plus(7, DateTimeUnit.DAY)
|
||||
/** The week starting at [start], shared between its pager page and [state]. Main thread only. */
|
||||
fun week(start: LocalDate): StateFlow<WeekUiState> = weeks.get(start)
|
||||
|
||||
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
|
||||
fun onPageSettled(page: Int) {
|
||||
val ws = firstDayOfWeek.value
|
||||
if (weekPageFor(_anchor.value, ws) != page) _anchor.value = weekStartForPage(page, ws)
|
||||
}
|
||||
|
||||
fun goToToday() {
|
||||
_anchor.value = todayDate
|
||||
}
|
||||
|
||||
/** Jump to the week containing [date] (drawer jump-to-date). */
|
||||
fun goToDate(date: LocalDate) {
|
||||
_anchor.value = date
|
||||
}
|
||||
|
||||
private fun buildState(
|
||||
start: LocalDate,
|
||||
calendars: List<CalendarSource>,
|
||||
|
||||
@@ -290,6 +290,9 @@
|
||||
<!-- Shared event strings -->
|
||||
<string name="event_untitled">(No title)</string>
|
||||
|
||||
<!-- Stands in for a calendar the provider gave no name (#329) -->
|
||||
<string name="calendar_unnamed">(No name)</string>
|
||||
|
||||
<!-- First-launch wizard (#163) -->
|
||||
<string name="onboarding_step_counter">Step %1$d of %2$d</string>
|
||||
<string name="onboarding_backup_title">Your events live only here</string>
|
||||
@@ -298,6 +301,26 @@
|
||||
<string name="onboarding_backup_benefit_folder_body">Backups are plain .ics files — put them somewhere that syncs, or on an SD card.</string>
|
||||
<string name="onboarding_backup_benefit_daily_title">Once a day, by itself</string>
|
||||
<string name="onboarding_backup_benefit_daily_body">Calendula exports your local calendars in the background. Change how often in Settings.</string>
|
||||
<string name="onboarding_calendars_title">Somewhere to put your events</string>
|
||||
<string name="onboarding_calendars_body">This device has no calendar yet. Calendula writes to the calendars already on your phone — it needs at least one.</string>
|
||||
<string name="onboarding_calendars_local_button">Create a calendar on this device</string>
|
||||
<string name="onboarding_calendars_caldav_button">Sync with a CalDAV server</string>
|
||||
<string name="onboarding_calendars_skip_button">Not now</string>
|
||||
<string name="onboarding_calendars_benefit_local_title">Stays on this phone</string>
|
||||
<string name="onboarding_calendars_benefit_local_body">A device calendar syncs nowhere, so back it up if the events matter.</string>
|
||||
<string name="onboarding_calendars_benefit_sync_title">Or sync from an account</string>
|
||||
<string name="onboarding_calendars_benefit_sync_body">Calendula shows what your accounts already sync. CalDAV servers need DAVx\u2075 to do the syncing.</string>
|
||||
<string name="onboarding_calendars_created_title">“%1$s” is ready</string>
|
||||
<string name="onboarding_calendars_created_body">It lives on this device and new events go into it unless you pick another calendar.</string>
|
||||
<string name="onboarding_calendars_created_more_title">Room for more</string>
|
||||
<string name="onboarding_calendars_created_more_body">Settings → Calendars makes as many as you need, and renames or recolours the ones you have.</string>
|
||||
<string name="onboarding_calendars_created_continue">Continue</string>
|
||||
<string name="onboarding_hidden_title">Your calendars are switched off</string>
|
||||
<string name="onboarding_hidden_body">Calendula shows the calendars this phone has switched on, and right now that is none of them. Switch on the ones you want to see — your other calendar apps show them again too.</string>
|
||||
<string name="onboarding_hidden_switch_all_button">Switch them all on</string>
|
||||
<string name="onboarding_hidden_skip_button">Not now</string>
|
||||
<string name="onboarding_hidden_failed">Couldn\'t switch that on. Settings → Calendars has the same switches.</string>
|
||||
<string name="onboarding_calendars_create_failed">Couldn\'t create the calendar. Your phone\'s calendar storage refused it.</string>
|
||||
<string name="onboarding_backup_enable_button">Choose folder and back up</string>
|
||||
<string name="onboarding_backup_skip_button">Not now</string>
|
||||
<string name="onboarding_view_title">What should open first?</string>
|
||||
@@ -673,7 +696,20 @@
|
||||
<string name="calendars_edit_title">Edit calendar</string>
|
||||
<string name="calendars_name_label">Name</string>
|
||||
<string name="calendars_color_label">Color</string>
|
||||
<string name="calendars_description_label">Description</string>
|
||||
<string name="calendars_description_hint">Add a description</string>
|
||||
<string name="color_name_mauve">Mauve</string>
|
||||
<string name="color_name_red">Red</string>
|
||||
<string name="color_name_orange">Orange</string>
|
||||
<string name="color_name_amber">Amber</string>
|
||||
<string name="color_name_olive">Olive</string>
|
||||
<string name="color_name_green">Green</string>
|
||||
<string name="color_name_teal">Teal</string>
|
||||
<string name="color_name_cyan">Cyan</string>
|
||||
<string name="color_name_blue">Blue</string>
|
||||
<string name="color_name_indigo">Indigo</string>
|
||||
<string name="color_name_purple">Purple</string>
|
||||
<string name="color_name_pink">Pink</string>
|
||||
<string name="calendars_delete_confirm_title">Delete calendar?</string>
|
||||
<string name="calendars_delete_confirm_message">\"%1$s\" and all of its events will be permanently removed from this device.</string>
|
||||
<string name="calendars_write_error">Couldn\'t save the change.</string>
|
||||
|
||||
@@ -64,9 +64,9 @@ class CalendarMapperTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null displayName falls back to placeholder`() {
|
||||
fun `null displayName is left blank for the UI to fill in`() {
|
||||
val src = reader(displayName = null).toCalendarSource()
|
||||
assertThat(src.displayName).isEqualTo(Fallbacks.UNNAMED_CALENDAR)
|
||||
assertThat(src.displayName).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -61,15 +61,15 @@ class InstanceMapperTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null title falls back to placeholder`() {
|
||||
fun `null title is left blank for the UI to fill in`() {
|
||||
val inst = reader(title = null).toEventInstance()
|
||||
assertThat(inst!!.title).isEqualTo(Fallbacks.UNTITLED_EVENT)
|
||||
assertThat(inst!!.title).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty title falls back to placeholder`() {
|
||||
fun `empty title is left blank for the UI to fill in`() {
|
||||
val inst = reader(title = "").toEventInstance()
|
||||
assertThat(inst!!.title).isEqualTo(Fallbacks.UNTITLED_EVENT)
|
||||
assertThat(inst!!.title).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -242,8 +242,8 @@ class EventSearchTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an untitled event's placeholder is not something the query can match`() {
|
||||
val untitled = candidate("(Ohne Titel)", description = "Titel folgt noch").copy(title = null)
|
||||
fun `an untitled event's blank title is not something the query can match`() {
|
||||
val untitled = candidate("", description = "Titel folgt noch").copy(title = null)
|
||||
|
||||
val results = search(untitled, query = "titel")
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.math.abs
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PageStateCacheTest {
|
||||
|
||||
@Test
|
||||
fun `the same key shares one state`() = runTest {
|
||||
val cache = PageStateCache<Int, Int>(backgroundScope, 0, { a, b -> abs(a - b) <= 1 }) { flow { emit(it) } }
|
||||
assertThat(cache.get(5)).isSameInstanceAs(cache.get(5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a dropped entry stops loading`() = runTest(UnconfinedTestDispatcher()) {
|
||||
var running = 0
|
||||
val cache = PageStateCache<Int, Int>(backgroundScope, 0, { a, b -> abs(a - b) <= 1 }) { key ->
|
||||
flow {
|
||||
running++
|
||||
try {
|
||||
emit(key)
|
||||
awaitCancellation()
|
||||
} finally {
|
||||
running--
|
||||
}
|
||||
}
|
||||
}
|
||||
backgroundScope.launch { cache.get(0).collect {} }
|
||||
assertThat(running).isEqualTo(1)
|
||||
|
||||
// Far enough away that 0 is let go, even though it is still collected.
|
||||
cache.get(10)
|
||||
assertThat(running).isEqualTo(0)
|
||||
assertThat(cache.size).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nearby entries are kept`() = runTest {
|
||||
val cache = PageStateCache<Int, Int>(backgroundScope, 0, { a, b -> abs(a - b) <= 2 }) { flow { emit(it) } }
|
||||
cache.get(4)
|
||||
cache.get(5)
|
||||
cache.get(6)
|
||||
assertThat(cache.size).isEqualTo(3)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.LocalDate
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class DayPagingTest {
|
||||
|
||||
@Test
|
||||
fun `a page and its day round-trip`() {
|
||||
listOf(LocalDate(2026, 9, 27), LocalDate(1990, 5, 16), LocalDate(2100, 2, 28)).forEach { day ->
|
||||
assertThat(dayForPage(dayPageFor(day))).isEqualTo(day)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adjacent pages are adjacent days across a month end`() {
|
||||
val page = dayPageFor(LocalDate(2026, 2, 28))
|
||||
assertThat(dayForPage(page + 1)).isEqualTo(LocalDate(2026, 3, 1))
|
||||
assertThat(dayForPage(page - 1)).isEqualTo(LocalDate(2026, 2, 27))
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ class OnboardingPlanTest {
|
||||
viewDone: Boolean = false,
|
||||
monthStyleDone: Boolean = false,
|
||||
backupApplies: Boolean? = null,
|
||||
hiddenDone: Boolean = false,
|
||||
hiddenApplies: Boolean? = false,
|
||||
calendarsDone: Boolean = false,
|
||||
calendarsApplies: Boolean? = false,
|
||||
visibilityArmed: Boolean = false,
|
||||
visibilityDone: Boolean = false,
|
||||
doneShown: Boolean = false,
|
||||
@@ -29,6 +33,10 @@ class OnboardingPlanTest {
|
||||
viewDone = viewDone,
|
||||
monthStyleDone = monthStyleDone,
|
||||
backupApplies = backupApplies,
|
||||
hiddenDone = hiddenDone,
|
||||
hiddenApplies = hiddenApplies,
|
||||
calendarsDone = calendarsDone,
|
||||
calendarsApplies = calendarsApplies,
|
||||
visibilityArmed = visibilityArmed,
|
||||
visibilityDone = visibilityDone,
|
||||
doneShown = doneShown,
|
||||
@@ -243,4 +251,144 @@ class OnboardingPlanTest {
|
||||
)
|
||||
assertThat(read.current).isNull()
|
||||
}
|
||||
|
||||
// --- the calendar step (#287) -----------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a device with no calendar is offered one, right after the grant`() {
|
||||
val fresh = plan(hasPermission = true, wizardArmed = true, calendarsApplies = null)
|
||||
assertThat(fresh.steps).contains(OnboardingStep.Calendars)
|
||||
assertThat(fresh.current).isEqualTo(OnboardingStep.Calendars)
|
||||
// Before reminders: there is no point asking about notifications for
|
||||
// events that have nowhere to live.
|
||||
assertThat(fresh.steps.indexOf(OnboardingStep.Calendars))
|
||||
.isLessThan(fresh.steps.indexOf(OnboardingStep.Reminders))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a device that already has a calendar never sees the step`() {
|
||||
val fresh = plan(hasPermission = true, wizardArmed = true, calendarsApplies = false)
|
||||
assertThat(fresh.steps).doesNotContain(OnboardingStep.Calendars)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the step is assumed to apply until a calendar list proves otherwise`() {
|
||||
// Same contract the backup step keeps: an unknown answer keeps the step,
|
||||
// so the plan can only ever get shorter and the counter never grows.
|
||||
val unknown = plan(hasPermission = true, wizardArmed = true, calendarsApplies = null)
|
||||
val known = plan(hasPermission = true, wizardArmed = true, calendarsApplies = false)
|
||||
assertThat(unknown.steps).contains(OnboardingStep.Calendars)
|
||||
assertThat(known.total).isLessThan(unknown.total)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `answering the step moves on without renumbering the flow`() {
|
||||
val before = plan(hasPermission = true, wizardArmed = true, calendarsApplies = null)
|
||||
val after = before.let {
|
||||
plan(
|
||||
hasPermission = true,
|
||||
wizardArmed = true,
|
||||
calendarsApplies = null,
|
||||
calendarsDone = true,
|
||||
)
|
||||
}
|
||||
assertThat(after.current).isEqualTo(OnboardingStep.Reminders)
|
||||
assertThat(after.steps).isEqualTo(before.steps)
|
||||
assertThat(after.total).isEqualTo(before.total)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an existing install is never given the calendar step`() {
|
||||
// Not gated on `fresh` would re-onboard everyone whose read momentarily
|
||||
// came back empty.
|
||||
val existing = plan(
|
||||
hasPermission = true,
|
||||
remindersDone = true,
|
||||
calendarsApplies = null,
|
||||
)
|
||||
assertThat(existing.steps).doesNotContain(OnboardingStep.Calendars)
|
||||
}
|
||||
|
||||
// --- every calendar switched off (#287) -------------------------------
|
||||
|
||||
@Test
|
||||
fun `a fresh install whose calendars are all off is shown them first`() {
|
||||
// Nothing visible leaves the calendar step unresolved too, which is what
|
||||
// puts the two in the same flow.
|
||||
val hidden = plan(
|
||||
hasPermission = true,
|
||||
wizardArmed = true,
|
||||
hiddenApplies = true,
|
||||
calendarsApplies = null,
|
||||
)
|
||||
assertThat(hidden.current).isEqualTo(OnboardingStep.HiddenCalendars)
|
||||
// Switching one back on is the lighter remedy, so it comes before the
|
||||
// offer to make a calendar.
|
||||
assertThat(hidden.steps.indexOf(OnboardingStep.HiddenCalendars))
|
||||
.isLessThan(hidden.steps.indexOf(OnboardingStep.Calendars))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `declining them leaves the offer to make a calendar standing`() {
|
||||
// The whole point of the order: someone who will not switch their
|
||||
// calendars on still gets the offer a device with none at all gets.
|
||||
val declined = plan(
|
||||
hasPermission = true,
|
||||
wizardArmed = true,
|
||||
hiddenApplies = true,
|
||||
hiddenDone = true,
|
||||
calendarsApplies = null,
|
||||
)
|
||||
assertThat(declined.current).isEqualTo(OnboardingStep.Calendars)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching one on drops the offer to make a calendar`() {
|
||||
// Something is showing now, so there is somewhere to put an event; the
|
||||
// step the user is standing on is held in place by the view model.
|
||||
val fixed = plan(
|
||||
hasPermission = true,
|
||||
wizardArmed = true,
|
||||
hiddenApplies = null,
|
||||
calendarsApplies = false,
|
||||
)
|
||||
assertThat(fixed.steps).contains(OnboardingStep.HiddenCalendars)
|
||||
assertThat(fixed.steps).doesNotContain(OnboardingStep.Calendars)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fresh install with something visible never sees the step`() {
|
||||
val visible = plan(hasPermission = true, wizardArmed = true, hiddenApplies = false)
|
||||
assertThat(visible.steps).doesNotContain(OnboardingStep.HiddenCalendars)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the step is assumed until the calendar list proves something is visible`() {
|
||||
// The unreadable answer keeps the step, like the ones around it: the flow
|
||||
// shrinks when the list arrives, and the counter never grows.
|
||||
val unknown = plan(hasPermission = true, wizardArmed = true, hiddenApplies = null)
|
||||
val known = plan(hasPermission = true, wizardArmed = true, hiddenApplies = false)
|
||||
assertThat(unknown.steps).contains(OnboardingStep.HiddenCalendars)
|
||||
assertThat(known.total).isLessThan(unknown.total)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an existing install that switched its own calendars off is left alone`() {
|
||||
// Hiding every calendar from Settings is a deliberate act with an empty
|
||||
// state of its own (#239); it must not reopen the wizard.
|
||||
val existing = plan(hasPermission = true, remindersDone = true, hiddenApplies = true)
|
||||
assertThat(existing.steps).isEmpty()
|
||||
assertThat(existing.current).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the upgrade notice stays a notice at the end of the flow`() {
|
||||
val armed = plan(
|
||||
hasPermission = true,
|
||||
remindersDone = true,
|
||||
visibilityArmed = true,
|
||||
hiddenApplies = true,
|
||||
)
|
||||
assertThat(armed.steps).containsExactly(OnboardingStep.Visibility)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.plus
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class WeekPagingTest {
|
||||
|
||||
// 2026-09-27 is a Sunday.
|
||||
private val sunday = LocalDate(2026, 9, 27)
|
||||
|
||||
@Test
|
||||
fun `a page and its week round-trip for every first day`() {
|
||||
DayOfWeek.entries.forEach { firstDay ->
|
||||
val page = weekPageFor(sunday, firstDay)
|
||||
val start = weekStartForPage(page, firstDay)
|
||||
assertThat(start).isEqualTo(sunday.startOfWeek(firstDay))
|
||||
assertThat(weekPageFor(start, firstDay)).isEqualTo(page)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every day of a week maps to the same page`() {
|
||||
val start = sunday.startOfWeek(DayOfWeek.MONDAY)
|
||||
val pages = (0..6).map { weekPageFor(start.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) }
|
||||
assertThat(pages.toSet()).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adjacent pages are adjacent weeks`() {
|
||||
val page = weekPageFor(sunday, DayOfWeek.SUNDAY)
|
||||
assertThat(weekStartForPage(page + 1, DayOfWeek.SUNDAY))
|
||||
.isEqualTo(LocalDate(2026, 10, 4))
|
||||
assertThat(weekStartForPage(page - 1, DayOfWeek.SUNDAY))
|
||||
.isEqualTo(LocalDate(2026, 9, 20))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dates before the epoch page resolve too`() {
|
||||
val old = LocalDate(1990, 5, 16)
|
||||
val page = weekPageFor(old, DayOfWeek.MONDAY)
|
||||
assertThat(weekStartForPage(page, DayOfWeek.MONDAY)).isEqualTo(LocalDate(1990, 5, 14))
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,19 @@ notice (#75) is a step in front of it rather than a dialog over the app — it
|
||||
is the only optional step an *already onboarded* install can be given, which
|
||||
is why it alone is not gated on the wizard being armed.
|
||||
|
||||
"Every calendar is switched off" is a *state*, not that announcement, so it
|
||||
is a step of its own at the front of the flow (#287) rather than another face
|
||||
of the notice: the notice is settled once and for good, while the state is
|
||||
derived from the live calendar list on every launch. It lists the calendars
|
||||
with their own switches — switching one on un-hides it in every other calendar
|
||||
app too, which is not a choice to make on the app's word — and it sits *in
|
||||
front of* the calendar step, so declining it falls through to the offer to
|
||||
make a calendar that a device with none at all gets. That is also why
|
||||
`calendarsApplies` resolves on "something is visible" rather than "the list is
|
||||
non-empty". Being derived, it is gated on the wizard being armed: an install
|
||||
that switched its own calendars off from Settings meant it, and has the in-app
|
||||
empty state (#239) instead.
|
||||
|
||||
## Recurring writes
|
||||
|
||||
The provider's invariants drive the design (learned the hard way, verified
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
New
|
||||
• Setup now makes sure you have a calendar to write to — create one, add an account, or switch a hidden one back on.
|
||||
|
||||
Changed
|
||||
• The month grid gives its days more room: smaller week numbers, a measured gutter, tighter chips.
|
||||
• A month day shows as many events as it has height for, instead of a flat three.
|
||||
• Split view no longer pads a short month out to six rows.
|
||||
|
||||
Fixed
|
||||
• Short events show their title in week and day view.
|
||||
• Untitled events and unnamed calendars read in your language.
|
||||
Reference in New Issue
Block a user