Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
112 changes: 40 additions & 72 deletions app/src/main/kotlin/org/fossify/calendar/fragments/WeekFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<Long>()
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) {
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<EventWeeklyView>, closeStartThreshold: Int) {
val sorted = events.sortedWith(
compareBy<EventWeeklyView> { it.range.lower }.thenByDescending { it.range.upper }
)

val placed = ArrayList<EventWeeklyView>()
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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import android.util.Range

data class EventWeeklyView(
val range: Range<Int>,
var slot: Int = 0,
var slotMax: Int = 0,
var collisions: ArrayList<Long> = 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,
)
1 change: 1 addition & 0 deletions app/src/main/res/layout/week_event_marker.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading