From 57f0ac82c1baa84584fb9e51b2ef96d72a941e20 Mon Sep 17 00:00:00 2001 From: oskarsss Date: Fri, 30 Oct 2026 18:02:03 +0200 Subject: [PATCH] feat: stack overlapping events in the weekly view Overlapping events used to split the day column into equal slots, so a long event got squeezed by any short event during it. Now the earliest (or longest) event keeps the full width and later overlapping events are drawn on top of it, indented like in desktop calendars. Events starting at about the same time leave the left half of the event below visible, so its title stays readable. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + .../calendar/fragments/WeekFragment.kt | 112 +++++++----------- .../calendar/helpers/WeeklyEventLayout.kt | 50 ++++++++ .../calendar/models/EventWeeklyView.kt | 7 +- app/src/main/res/layout/week_event_marker.xml | 1 + 5 files changed, 97 insertions(+), 75 deletions(-) create mode 100644 app/src/main/kotlin/org/fossify/calendar/helpers/WeeklyEventLayout.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e7a4bcdc..a80641ab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ 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). ## [Unreleased] +### Changed +- Overlapping events in the weekly view are now stacked on top of each other instead of being squeezed side by side ## [1.12.1] - 2026-10-22 ### Changed diff --git a/app/src/main/kotlin/org/fossify/calendar/fragments/WeekFragment.kt b/app/src/main/kotlin/org/fossify/calendar/fragments/WeekFragment.kt index 1f15edd71..1fc535b37 100644 --- a/app/src/main/kotlin/org/fossify/calendar/fragments/WeekFragment.kt +++ b/app/src/main/kotlin/org/fossify/calendar/fragments/WeekFragment.kt @@ -5,7 +5,9 @@ import android.content.ClipData import android.content.ClipDescription import android.content.Intent import android.content.res.Resources +import android.graphics.Color import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.GradientDrawable import android.os.Bundle import android.os.Handler import android.util.Range @@ -18,6 +20,7 @@ import android.view.View import android.view.ViewGroup import android.widget.RelativeLayout import androidx.collection.LongSparseArray +import androidx.core.graphics.ColorUtils import androidx.fragment.app.Fragment import org.fossify.calendar.R import org.fossify.calendar.activities.MainActivity @@ -54,6 +57,7 @@ import org.fossify.calendar.helpers.TYPE_EVENT import org.fossify.calendar.helpers.TYPE_TASK import org.fossify.calendar.helpers.WEEK_START_TIMESTAMP import org.fossify.calendar.helpers.WeeklyCalendarImpl +import org.fossify.calendar.helpers.WeeklyEventLayout import org.fossify.calendar.helpers.getActivityToOpen import org.fossify.calendar.helpers.isWeekend import org.fossify.calendar.interfaces.WeekFragmentListener @@ -69,6 +73,7 @@ import org.fossify.commons.extensions.beGone import org.fossify.commons.extensions.beVisible import org.fossify.commons.extensions.beVisibleIf import org.fossify.commons.extensions.getContrastColor +import org.fossify.commons.extensions.getProperBackgroundColor import org.fossify.commons.extensions.getProperPrimaryColor import org.fossify.commons.extensions.getProperTextColor import org.fossify.commons.extensions.hideKeyboard @@ -668,70 +673,20 @@ class WeekFragment : Fragment(), WeeklyCalendar { } while (currentDayCode.toInt() <= endDayCode.toInt()) } + // make sure the title of the event below stays visible, even with a bigger minimal event height + val closeStartThreshold = max( + WeeklyEventLayout.CLOSE_START_THRESHOLD_MINUTES, + (minimalHeight / minuteHeight).roundToInt() + ) for ((_, eventDayList) in eventTimeRanges) { - val eventsCollisionChecked = ArrayList() - for ((eventId, eventWeeklyView) in eventDayList) { - if (eventWeeklyView.slot == 0) { - eventWeeklyView.slot = 1 - eventWeeklyView.slotMax = 1 - } - - eventsCollisionChecked.add(eventId) - val eventWeeklyViewsToCheck = - eventDayList.filterNot { eventsCollisionChecked.contains(it.key) } - for ((toCheckId, eventWeeklyViewToCheck) in eventWeeklyViewsToCheck) { - val areTouching = eventWeeklyView.range.intersects(eventWeeklyViewToCheck.range) - val doHaveCommonMinutes = if (areTouching) { - eventWeeklyView.range.upper > eventWeeklyViewToCheck.range.lower || (eventWeeklyView.range.lower == eventWeeklyView.range.upper && - eventWeeklyView.range.upper == eventWeeklyViewToCheck.range.lower) - } else { - false - } - - if (areTouching && doHaveCommonMinutes) { - if (eventWeeklyViewToCheck.slot == 0) { - val collisionEventWeeklyViews = eventDayList - .filter { eventWeeklyView.collisions.contains(it.key) } - var currentSlotMax = max( - eventWeeklyView.slotMax.coerceAtLeast(1), - eventWeeklyView.slot.coerceAtLeast(1) - ) - val maxCollisionSlot = collisionEventWeeklyViews - .maxOfOrNull { it.value.slot.coerceAtLeast(1) } ?: 1 - currentSlotMax = max(currentSlotMax, maxCollisionSlot) - val nextSlot = currentSlotMax + 1 - val slotRange = IntArray(currentSlotMax) { it + 1 } - for ((_, collisionEventWeeklyView) in collisionEventWeeklyViews) { - if ( - collisionEventWeeklyView.range.intersects(eventWeeklyViewToCheck.range) - && collisionEventWeeklyView.slot in 1..currentSlotMax - ) { - slotRange[collisionEventWeeklyView.slot - 1] = nextSlot - } - } - if (eventWeeklyView.slot in 1..currentSlotMax) { - slotRange[eventWeeklyView.slot - 1] = nextSlot - } - - val slot = slotRange.minOrNull() ?: nextSlot - eventWeeklyViewToCheck.slot = slot + WeeklyEventLayout.arrange(eventDayList.values, closeStartThreshold) + } - if (slot == nextSlot) { - eventWeeklyViewToCheck.slotMax = nextSlot - eventWeeklyView.slotMax = nextSlot - for ((_, collisionEventWeeklyView) in collisionEventWeeklyViews) { - collisionEventWeeklyView.slotMax = - max(collisionEventWeeklyView.slotMax, nextSlot) - } - } else { - eventWeeklyViewToCheck.slotMax = currentSlotMax - } - } - eventWeeklyView.collisions.add(toCheckId) - eventWeeklyViewToCheck.collisions.add(eventId) - } - } - } + val isRtl = requireContext().isRTLLayout + val eventBorderColor = if (isPrintVersion) { + Color.WHITE + } else { + requireContext().getProperBackgroundColor() } dayevents@ for (event in events) { @@ -765,7 +720,7 @@ class WeekFragment : Fragment(), WeeklyCalendar { event.color } var textColor = backgroundColor.getContrastColor() - val currentEventWeeklyView = eventTimeRanges[currentDayCode]!![event.id] + val currentEventWeeklyView = eventTimeRanges[currentDayCode]!![event.id]!! val adjustAlpha = if (event.isTask()) { dimCompletedTasks && event.isTaskCompleted() @@ -774,13 +729,26 @@ class WeekFragment : Fragment(), WeeklyCalendar { } if (adjustAlpha) { - backgroundColor = backgroundColor.adjustAlpha(MEDIUM_ALPHA) + // keep the color opaque, so that overlapping events don't show through + backgroundColor = ColorUtils.compositeColors( + backgroundColor.adjustAlpha(MEDIUM_ALPHA), + eventBorderColor + ) textColor = textColor.adjustAlpha(HIGHER_ALPHA) } - root.background = ColorDrawable(backgroundColor) + root.background = if (currentEventWeeklyView.indent > 0f) { + GradientDrawable().apply { + setColor(backgroundColor) + setStroke(density, eventBorderColor) + } + } else { + ColorDrawable(backgroundColor) + } dayColumn.addView(root) - root.y = currentEventWeeklyView!!.range.lower * minuteHeight + root.outlineProvider = null + root.translationZ = currentEventWeeklyView.layer.toFloat() + root.y = currentEventWeeklyView.range.lower * minuteHeight // compensate grid offset root.y -= (currentEventWeeklyView.range.lower / 60) / 2 @@ -810,12 +778,12 @@ class WeekFragment : Fragment(), WeeklyCalendar { } (root.layoutParams as RelativeLayout.LayoutParams).apply { - width = (dayColumn.width - 1) / currentEventWeeklyView.slotMax - root.x = (width * (currentEventWeeklyView.slot - 1)).toFloat() - if (currentEventWeeklyView.slot > 1) { - root.x += density - width -= density - } + addRule(RelativeLayout.ALIGN_PARENT_LEFT) + val columnWidth = dayColumn.width - 1 + val left = columnWidth * currentEventWeeklyView.indent + width = (columnWidth - left).toInt() + // in RTL the title is on the right, so nested events are indented from the right side + root.x = if (isRtl) 0f else left } root.setOnClickListener { diff --git a/app/src/main/kotlin/org/fossify/calendar/helpers/WeeklyEventLayout.kt b/app/src/main/kotlin/org/fossify/calendar/helpers/WeeklyEventLayout.kt new file mode 100644 index 000000000..3c3e65a36 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/calendar/helpers/WeeklyEventLayout.kt @@ -0,0 +1,50 @@ +package org.fossify.calendar.helpers + +import org.fossify.calendar.models.EventWeeklyView +import kotlin.math.min + +/** + * Lays out overlapping events of a single day the way desktop calendars do: the earliest event (or the longest one, + * if they start at the same time) keeps the full width and every later overlapping event is drawn on top of it, + * indented to the right. + */ +object WeeklyEventLayout { + const val CLOSE_START_THRESHOLD_MINUTES = 30 + + // a nested event covers this part of the width of the event below it + private const val NESTED_INDENT_RATIO = 0.25f + + // an event starting at about the same time would hide the title of the event below it, so it covers only half of it + private const val CLOSE_START_INDENT_RATIO = 0.5f + + // keep deeply nested events wide enough to be visible and tappable + private const val MAX_INDENT = 0.8f + + /** + * @param closeStartThreshold events starting less than this many minutes after the event below them + * are indented more + */ + fun arrange(events: Collection, closeStartThreshold: Int) { + val sorted = events.sortedWith( + compareBy { it.range.lower }.thenByDescending { it.range.upper } + ) + + val placed = ArrayList() + for ((layer, event) in sorted.withIndex()) { + // nest over the topmost event still visible when this one starts, events that already ended don't matter + val parent = placed + .filter { it.range.upper > event.range.lower } + .maxByOrNull { it.indent } + + event.indent = if (parent == null) { + 0f + } else { + val startsClose = event.range.lower - parent.range.lower < closeStartThreshold + val ratio = if (startsClose) CLOSE_START_INDENT_RATIO else NESTED_INDENT_RATIO + min(parent.indent + (1 - parent.indent) * ratio, MAX_INDENT) + } + event.layer = layer + placed.add(event) + } + } +} diff --git a/app/src/main/kotlin/org/fossify/calendar/models/EventWeeklyView.kt b/app/src/main/kotlin/org/fossify/calendar/models/EventWeeklyView.kt index 68163e258..c3606a523 100644 --- a/app/src/main/kotlin/org/fossify/calendar/models/EventWeeklyView.kt +++ b/app/src/main/kotlin/org/fossify/calendar/models/EventWeeklyView.kt @@ -4,7 +4,8 @@ import android.util.Range data class EventWeeklyView( val range: Range, - var slot: Int = 0, - var slotMax: Int = 0, - var collisions: ArrayList = ArrayList() + // the left offset as a fraction of the day column width, events nested over earlier ones are indented + var indent: Float = 0f, + // the drawing order within the day, events nested over earlier ones are drawn on top of them + var layer: Int = 0, ) diff --git a/app/src/main/res/layout/week_event_marker.xml b/app/src/main/res/layout/week_event_marker.xml index aa34e2648..4e76f856d 100644 --- a/app/src/main/res/layout/week_event_marker.xml +++ b/app/src/main/res/layout/week_event_marker.xml @@ -29,6 +29,7 @@ android:includeFontPadding="false" android:maxLines="3" android:paddingStart="@dimen/tiny_margin" + android:textAlignment="viewStart" android:textColor="@android:color/white" android:textSize="@dimen/small_text_size" app:layout_constraintEnd_toEndOf="parent"