Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
137f87f
chore: Add Paging 3 dependencies
sirambd Jul 27, 2026
74b0525
feat: Page the Planning composable with Paging 3 via PlanningRow
sirambd Jul 27, 2026
a768184
feat: Drive the planning from a Paging source instead of an eager map
sirambd Jul 27, 2026
9cb0c1c
feat: Wire the planning screen to Paging with recentering jumps and c…
sirambd Jul 27, 2026
0b6e51d
docs: Document the planning Paging 3 architecture
sirambd Jul 27, 2026
3910e50
chore: Revert Empty state
sirambd Jul 27, 2026
799c4e0
chore: Enhance error handling in PlanningPagingSource with cancellabl…
sirambd Jul 27, 2026
9703a5a
refactor: Rename observerScope to coroutineScope for clarity
sirambd Jul 27, 2026
6a4ef74
feat: Add maxSize limit to Paging for memory management
sirambd Jul 27, 2026
8d40fda
refactor: Improve observer handling in PlanningPagingSource for bette…
sirambd Jul 27, 2026
8c08438
refactor: Clarify comments in PlanningViewModel for prefetching logic
sirambd Jul 27, 2026
88aefc9
fix: Fix planning drift by pinning the list to a target date
sirambd Jul 28, 2026
46a1209
chore: Remove useless comment
sirambd Aug 21, 2026
d19f22c
chore: Improve the way we download data
tevincent Sep 17, 2026
3af808e
fix: Change the selected date every time we scroll and avoid being te…
tevincent Sep 23, 2026
be31cd6
chore: Update AGENTS.md file
tevincent Sep 23, 2026
8805126
chore: Update LICENSES.md file
tevincent Sep 23, 2026
fad785c
fix: Copilots review
tevincent Sep 23, 2026
0e7773e
fix: Avoid displaying the wrong date and moving the viewport to the b…
tevincent Sep 23, 2026
4e0f85d
fix: Fix SonarQube issue
tevincent Sep 23, 2026
2369feb
chore: Update libs
tevincent Sep 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ When adding a new CalendarComponents module, apply the flavor-aware plugin if it
| `:CalendarComponents:Foundation` | `com.infomaniak.calendar.components.foundation` | Shared models (`EventSummaryUi`, `YearWeek`, `WeekNumbering`) and base Compose components (`DayCircle`, `DateState`, the search screens' `SearchBar` and `EmptyState`) used by all other CalendarComponents modules. |
| `:CalendarComponents:Resources` | `com.infomaniak.calendar.components.resources` | String-only module: `res/values/strings.xml` (+ translations). No Kotlin code, no Compose. Centralises all CalendarComponents string resources. |
| `:CalendarComponents:Event` | `com.infomaniak.calendar.components.event` | `EventItem` Composable — renders a single event row. Re-exports Foundation via `api`. |
| `:CalendarComponents:Planning` | `com.infomaniak.calendar.components.planning` | `Planning` Composable — a `LazyColumn` with ISO week headers and per-day event lists. Also provides the `stickyWithinItem` `Modifier` extension. Re-exports Foundation via `api` (its types appear in `Planning`'s public signature); Event and Resources are internal implementation details and stay `implementation`. Week header design is a **placeholder**. |
| `:CalendarComponents:Planning` | `com.infomaniak.calendar.components.planning` | `Planning` Composable — a `LazyColumn` driven by `LazyPagingItems<PlanningRow>` (Paging 3), with ISO week headers and one lazy item per event. `PlanningRow` is the flat paged row type (`WeekHeader` / `Event`); `planningRows(week, days)` flattens a week into rows. Also provides the `stickyDayIndicator` `Modifier` extension. Re-exports Foundation and Paging Compose via `api` because both appear in its public signature; Event and Resources are internal implementation details and stay `implementation`. Week header design is a **placeholder**. |
| `:CalendarComponents:Day` | `com.infomaniak.calendar.components.day` | Day view — a day's header, its all-day band, and the scrollable hour grid carrying its timed events, with the current time indicator and pinch-to-zoom over it. Holds `resolveOverlaps`, the pure-Kotlin solver placing concurrent events, ported from the [Eventually](https://github.com/claustrofob/Eventually) SwiftUI layout the iOS calendar uses so both platforms arrange a day identically. `TimedEventsColumn` resolves and places a day's timed events within its own bounds (no knowledge of the hour gutter), so any timeline can position one column per day. The grid is split the same way: `HourLabels` (the gutter's hour labels only) and `HourLines` (a line per hour across the width it is given), both sized by `Modifier.timelineHeight`, so each screen composes the gutter, lines and columns it needs. `CurrentTimeIndicator` likewise draws across its own bounds (its dot on the start edge), and `Modifier.verticalTimelineScroll` gives every timeline the same vertical scroll and room around the grid. `MultiDayPager` assembles these same blocks for several days side by side (one header and event column per day, laid out by a shared column layout so they line up), behind `ThreeDayPager` (3 days from the selected date) and `WeekPager` (the selected date's week); neither swipes yet. Re-exports Foundation via `api`. |
| `:CalendarComponents:EventDetail` | `com.infomaniak.calendar.components.eventdetail` | Event detail module — the `detail` package contains the read-only `EventDetail` Composable, while the `form` package contains the editable `EventForm` shared by event editing and creation. Matching fields can opt into shared-element transitions through optional Compose animation scopes and internal element keys, without depending on app navigation or domain identifier types. Re-exports Foundation and `core.infomaniak.core.filetypes` via `api` since both appear in `EventDetailUi`. |
| `:CalendarComponents:TimeZoneSearch` | `com.infomaniak.calendar.components.timezonesearch` | `TimeZoneSearch` Composable — searchable list of ICU's canonical location time zones, each with its localized name, city and country, and UTC offset (formatters in Foundation's `TimeZoneFormatter`). Filters locally; reports the clicked `TimeZone`. Re-exports Foundation and `kotlinx-datetime` via `api`. |
Expand All @@ -164,7 +164,8 @@ public `EventItem` signature exposes `Foundation` types. `Planning` and `Day` ea
dependency on `Foundation` (its types appear in their own public signatures) and depend on `Event` and `Resources` as
`implementation` only (internal implementation details, not part of their own public API surface — not re-exported).
`Planning` and `Day` are the two top-level entry points for consumers, one per calendar view; each transitively brings
in the stack it needs.
in the stack it needs. `Planning` also exposes Paging Compose through `api`, since `LazyPagingItems` is part of its public
`Planning` signature.

### What is final vs placeholder

Expand All @@ -187,6 +188,7 @@ in the stack it needs.
- `kmpCalendar.kotlinx.datetime` — `kotlinx-datetime` types (`LocalDate`, `TimeZone`, …) (Foundation + Planning).
- `libs.compose.material3` — Material 3 Compose (from `libs` catalog).
- `libs.infomaniak.designsystem.theme.calendar` — Infomaniak Design System.
- `libs.paging.compose` — Paging 3 Compose (`LazyPagingItems`, `collectAsLazyPagingItems`) exposed by `Planning`'s public API.

### Ownership

Expand Down
2 changes: 2 additions & 0 deletions CalendarComponents/Planning/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -68,5 +68,7 @@ dependencies {
implementation(libs.compose.material3)
debugImplementation(core.compose.ui.tooling)

api(libs.paging.compose)

implementation(kmpCalendar.kotlinx.datetime)
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,23 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.paging.PagingData
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemContentType
import androidx.paging.compose.itemKey
import com.infomaniak.calendar.components.event.EventItem
import com.infomaniak.calendar.components.event.EventItemDefaults
import com.infomaniak.calendar.components.foundation.models.EventSummaryUi
Expand All @@ -46,14 +51,15 @@ import com.infomaniak.calendar.components.foundation.utils.timeFormatter.formatS
import com.infomaniak.calendar.components.planning.component.DayIndicator
import com.infomaniak.calendar.components.planning.component.emptyState.OtherDayEmptyState
import com.infomaniak.calendar.components.planning.component.emptyState.TodayEmptyState
import com.infomaniak.calendar.components.planning.preview.WeekEventsPreviewParameter
import com.infomaniak.calendar.components.planning.preview.PlanningRowPreviewParameter
import com.infomaniak.calendar.components.resources.R
import com.infomaniak.core.ui.compose.margin.Margin
import kotlinx.coroutines.flow.flowOf
import kotlinx.datetime.LocalDate

@Composable
fun Planning(
weekEvents: () -> Map<YearWeek, Map<LocalDate, List<EventSummaryUi>>>,
rows: LazyPagingItems<PlanningRow>,
goToEventCreation: (LocalDate) -> Unit,
onEventClick: (EventSummaryUi.Normal) -> Unit,
modifier: Modifier = Modifier,
Expand All @@ -62,7 +68,7 @@ fun Planning(
) {
Timeline(
lazyListState = lazyListState,
weekEvents = weekEvents,
rows = rows,
goToEventCreation = goToEventCreation,
onEventClick = onEventClick,
contentPadding = contentPadding,
Expand All @@ -73,14 +79,13 @@ fun Planning(
@Composable
private fun Timeline(
lazyListState: LazyListState,
weekEvents: () -> Map<YearWeek, Map<LocalDate, List<EventSummaryUi>>>,
rows: LazyPagingItems<PlanningRow>,
goToEventCreation: (LocalDate) -> Unit,
onEventClick: (EventSummaryUi.Normal) -> Unit,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
) {
val today by rememberToday()
val events = weekEvents()
val sectionSizing = remember { SectionSizing() }

LazyColumn(
Expand All @@ -89,38 +94,54 @@ private fun Timeline(
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(Margin.Mini),
) {
events.forEach { (week, days) ->
item(key = PlanningItemKey.WeekHeader(week.firstDay)) {
Text(week.label, modifier = Modifier.padding(bottom = Margin.Medium))
}

days.forEach { (date, events) ->
val sectionItemKeys = events.map { it.toItemKey(date) }

itemsIndexed(events, key = { _, event -> event.toItemKey(date) }) { index, event ->
val itemKey = event.toItemKey(date)
val bottomPadding = if (index == events.lastIndex) Margin.Medium else 0.dp

Event(
event = event,
date = date,
today = today,
lazyListState = lazyListState,
sectionSizing = sectionSizing,
itemKey = itemKey,
sectionItemKeys = sectionItemKeys,
goToEventCreation = goToEventCreation,
onEventClick = onEventClick,
modifier = Modifier
.ensureSectionMinHeight(sectionSizing, sectionItemKeys, itemKey)
.padding(bottom = bottomPadding),
)
items(
count = rows.itemCount,
key = rows.itemKey { it.key },
contentType = rows.itemContentType { it.contentType },
) { index ->
when (val row = rows[index]) {
is PlanningRow.WeekHeader -> {
Text(row.week.label, modifier = Modifier.padding(bottom = Margin.Medium))
}
is PlanningRow.Event -> TimelineEvent(
row = row,
today = today,
lazyListState = lazyListState,
sectionSizing = sectionSizing,
goToEventCreation = goToEventCreation,
onEventClick = onEventClick,
)
null -> Unit
}
}
}
}

@Composable
private fun TimelineEvent(
row: PlanningRow.Event,
today: LocalDate,
lazyListState: LazyListState,
sectionSizing: SectionSizing,
goToEventCreation: (LocalDate) -> Unit,
onEventClick: (EventSummaryUi.Normal) -> Unit,
) {
Event(
event = row.event,
date = row.date,
today = today,
lazyListState = lazyListState,
sectionSizing = sectionSizing,
itemKey = row.key,
sectionItemKeys = row.daySectionKeys,
goToEventCreation = goToEventCreation,
onEventClick = onEventClick,
modifier = Modifier
.ensureSectionMinHeight(sectionSizing, row.daySectionKeys, row.key)
.padding(bottom = if (row.isLastInDay) Margin.Medium else 0.dp),
)
}

@Composable
private fun Event(
event: EventSummaryUi,
Expand All @@ -134,6 +155,11 @@ private fun Event(
onEventClick: (EventSummaryUi.Normal) -> Unit,
modifier: Modifier = Modifier,
) {
DisposableEffect(itemKey, sectionItemKeys) {
sectionSizing.retainSection(sectionItemKeys)
onDispose { sectionSizing.releaseSection(sectionItemKeys) }
}

Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(Margin.Small),
Expand Down Expand Up @@ -169,12 +195,14 @@ private val YearWeek.label: String
return "$week - $dateRange"
}

private fun EventSummaryUi.toItemKey(date: LocalDate): PlanningItemKey = PlanningItemKey.Event(date = date, id = id)

@Preview
@Composable
private fun PreviewPlanning(@PreviewParameter(WeekEventsPreviewParameter::class) weekEvents: Map<YearWeek, Map<LocalDate, List<EventSummaryUi>>>) {
private fun PreviewPlanning(@PreviewParameter(PlanningRowPreviewParameter::class) rows: List<PlanningRow>) {
Surface {
Planning(goToEventCreation = {}, onEventClick = {}, weekEvents = { weekEvents })
Planning(
goToEventCreation = {},
onEventClick = {},
rows = flowOf(PagingData.from(rows)).collectAsLazyPagingItems(),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Infomaniak Calendar - Android
* Copyright (C) 2026 Infomaniak Network SA
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.infomaniak.calendar.components.planning

import com.infomaniak.calendar.components.foundation.models.EventSummaryUi
import com.infomaniak.calendar.components.foundation.models.YearWeek
import kotlinx.datetime.LocalDate

/**
* A single flat row of the planning timeline — the value type paged by Paging 3.
*
* Keeping the paged type at *row* granularity (rather than a whole week) is what lets a
* `LazyPagingItems` present one lazy item per event: this preserves per-event virtualization,
* fine-grained anchor/prefetch, and the sticky day indicator, while the `PagingSource` still loads
* one ISO week at a time. Each [Event] carries its day's section keys ([Event.daySectionKeys]) so the
* sticky indicator and section min-height keep working without the surrounding day grouping.
*/
sealed interface PlanningRow {
val key: PlanningItemKey
val contentType: Any

data class WeekHeader(val week: YearWeek) : PlanningRow {
override val key: PlanningItemKey get() = PlanningItemKey.WeekHeader(week.firstDay)
override val contentType: Any get() = ContentType.WeekHeader
}

data class Event(
val date: LocalDate,
val event: EventSummaryUi,
val daySectionKeys: List<PlanningItemKey>,
val isLastInDay: Boolean,
) : PlanningRow {
override val key: PlanningItemKey get() = PlanningItemKey.Event(date = date, id = event.id)
override val contentType: Any get() = ContentType.Event
}

private enum class ContentType { WeekHeader, Event }
}

/**
* Flattens a single [week] together with its per-day [EventSummaryUi]s into the flat [PlanningRow] stream a
* `LazyPagingItems` presents: one [PlanningRow.WeekHeader] followed by one [PlanningRow.Event] per
* event, in [days] iteration order (expected to be day-ascending, all-day first then by start time).
*
* Every event of a day shares that day's [PlanningRow.Event.daySectionKeys] so the sticky day
* indicator can pin itself for the lifetime of the day section.
*/
fun planningRows(week: YearWeek, days: Map<LocalDate, List<EventSummaryUi>>): List<PlanningRow> = buildList {
add(PlanningRow.WeekHeader(week))

for ((date, events) in days) {
val sectionKeys = events.map { PlanningItemKey.Event(date = date, id = it.id) }
events.forEachIndexed { index, event ->
add(
PlanningRow.Event(
date = date,
event = event,
daySectionKeys = sectionKeys,
isLastInDay = index == events.lastIndex,
),
)
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import com.infomaniak.calendar.components.planning.component.DayIndicator
@Stable
internal class SectionSizing {
private val rowHeights = mutableStateMapOf<Any, Int>()
private val sectionReferenceCounts = mutableMapOf<List<Any>, Int>()
private var indicatorHeight by mutableIntStateOf(0)

/** Record the size of a [DayIndicator] so we can compute what size should be allocated */
Expand All @@ -45,6 +46,23 @@ internal class SectionSizing {
if (rowHeights[key] != height) rowHeights[key] = height
}

/** Keep a section's measurements only while at least one of its rows is composed. */
fun retainSection(sectionItemKeys: List<Any>) {
sectionReferenceCounts[sectionItemKeys] = (sectionReferenceCounts[sectionItemKeys] ?: 0) + 1
}

/** Release every measurement in a section once Paging disposes all of its rows. */
fun releaseSection(sectionItemKeys: List<Any>) {
val remainingReferences = (sectionReferenceCounts[sectionItemKeys] ?: return) - 1
if (remainingReferences > 0) {
sectionReferenceCounts[sectionItemKeys] = remainingReferences
return
}

sectionReferenceCounts.remove(sectionItemKeys)
sectionItemKeys.forEach { rowHeights.remove(it) }
}

/** Extra height needed so the section is at least as tall as the indicator. */
fun deficitFor(sectionItemKeys: List<Any>): Int {
val sectionHeight = sectionItemKeys.sumOf { rowHeights[it] ?: 0 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import com.infomaniak.calendar.components.foundation.models.SimpleEventSummaryUi
import com.infomaniak.calendar.components.foundation.models.WeekNumbering
import com.infomaniak.calendar.components.foundation.models.YearWeek
import com.infomaniak.calendar.components.foundation.preview.EventColorsUiFactory
import com.infomaniak.calendar.components.planning.PlanningRow
import com.infomaniak.calendar.components.planning.planningRows
import com.infomaniak.core.common.utils.today
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
Expand All @@ -38,10 +40,10 @@ import kotlinx.datetime.toInstant
import kotlin.time.Clock
import kotlin.time.Instant

class WeekEventsPreviewParameter : PreviewParameterProvider<Map<YearWeek, Map<LocalDate, List<EventSummaryUi>>>> {
override val values: Sequence<Map<YearWeek, Map<LocalDate, List<EventSummaryUi>>>> = sequenceOf(
todayPreviewWeekEvents,
passingYearPreviewWeekEvents,
class PlanningRowPreviewParameter : PreviewParameterProvider<List<PlanningRow>> {
override val values: Sequence<List<PlanningRow>> = sequenceOf(
todayPreviewWeekEvents.toPlanningRows(),
passingYearPreviewWeekEvents.toPlanningRows(),
)

companion object {
Expand All @@ -55,6 +57,9 @@ class WeekEventsPreviewParameter : PreviewParameterProvider<Map<YearWeek, Map<Lo
}
}

private fun Map<YearWeek, Map<LocalDate, List<EventSummaryUi>>>.toPlanningRows(): List<PlanningRow> =
flatMap { (week, days) -> planningRows(week, days) }

private fun generateEventsAround(targetDay: LocalDate): Map<YearWeek, Map<LocalDate, List<EventSummaryUi>>> {
val timeZone = TimeZone.currentSystemDefault()

Expand Down
Loading
Loading