diff --git a/core/ktx/src/main/java/de/mm20/launcher2/ktx/Iterable.kt b/core/ktx/src/main/java/de/mm20/launcher2/ktx/Iterable.kt index 8e878765..23fdff0f 100644 --- a/core/ktx/src/main/java/de/mm20/launcher2/ktx/Iterable.kt +++ b/core/ktx/src/main/java/de/mm20/launcher2/ktx/Iterable.kt @@ -9,6 +9,28 @@ fun Iterable.flatMapNotNull(transform: (T) -> Iterable?) : List { return destination } +/** + * Converts an Iterable of pairs into a Map where the keys are the first elements of the pairs + * and the values are lists of the second elements of the pairs. + * + * For example, given the input: + * ``` + * val pairs = listOf( + * "a" to 1, + * "b" to 2, + * "a" to 3, + * "c" to 4, + * ) + * ``` + * the output will be: + * ``` + * mapOf( + * "a" to listOf(1, 3), + * "b" to listOf(2), + * "c" to listOf(4), + * ) + * ``` + */ fun Iterable>.toMultiMap() : Map> { val destination = mutableMapOf>() for ((k, v) in this) { diff --git a/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocation.kt b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocation.kt index c1aa85c1..afc2f586 100644 --- a/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocation.kt +++ b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocation.kt @@ -337,43 +337,61 @@ internal fun parseOpeningSchedule( ): OpeningSchedule? { val parsed = it?.toOpeningHoursOrNull(lenient = true) ?: return null - if (parsed.rules.singleOrNull()?.selector is TwentyFourSeven) + if (parsed.rules.singleOrNull()?.selector is TwentyFourSeven) { return OpeningSchedule.TwentyFourSeven + } val rangeRules = parsed.rules.mapNotNull { it.selector as? Range } - val applicableRules = rangeRules - .flatMapNotNull { range -> - with(range) { - when { - weekdays != null -> weekdays!!.flatMap { - when (it) { - is Weekday -> listOf(it to range) - is WeekdayRange -> (it.start.ordinal..it.end.ordinal).map { Weekday.entries[it] to range } - is SpecificWeekdays -> listOf(it.weekday to range) + // Group rules by the weekdays they apply to. Rules can apply to multiple weekdays. + val rulesMap = mutableMapOf>( + Weekday.Monday to mutableListOf(), + Weekday.Tuesday to mutableListOf(), + Weekday.Wednesday to mutableListOf(), + Weekday.Thursday to mutableListOf(), + Weekday.Friday to mutableListOf(), + Weekday.Saturday to mutableListOf(), + Weekday.Sunday to mutableListOf() + ) + + for (rule in rangeRules) { + if (rule.weekdays != null) { + for (selector in rule.weekdays!!) { + when (selector) { + is Weekday -> rulesMap[selector]!!.add(rule) + is WeekdayRange -> { + for (weekday in selector.start.ordinal..selector.end.ordinal) { + rulesMap[Weekday.entries[weekday]]!!.add(rule) } } - - times.isNullOrEmpty().not() || months.isNullOrEmpty().not() -> - Weekday.entries.map { it to range } - - else -> null + is SpecificWeekdays -> { + rulesMap[selector.weekday]!!.add(rule) + } } } - }.toMultiMap().mapNotNull { (day, rules) -> - rules.filterYears(localTime) - .filterMonths(localTime) - .filterNthDays(localTime) - .map { it.copy(weekdays = listOf(day)) } - .singleOrNull() + } else if (!rule.times.isNullOrEmpty() || !rule.months.isNullOrEmpty()) { + rulesMap.forEach { _, it -> + it.add(rule) + } } + } + + // Filter out rules that are not valid for the current year, month, and week. Hopefully, + // there is only one rule left per weekday. If not, skip that weekday. + val applicableRules = rulesMap.mapNotNull { (day, rules) -> + rules.filterYears(localTime) + .filterMonths(localTime) + .filterNthDays(localTime) + .map { it.copy(weekdays = listOf(day)) } + .singleOrNull() + } val hours = mutableListOf() for (range in applicableRules) { val localTimesWithDuration = - range.times?.flatMap { it.toLocalTimeWithDuration() } ?: continue + range.times?.mapNotNull { it.toLocalTimeWithDuration() } ?: continue val daysOfWeek = range.weekdays .ifNullOrEmpty { Weekday.entries.toList() } .flatMap { it.toDaysOfWeek() } @@ -389,27 +407,35 @@ internal fun parseOpeningSchedule( return OpeningSchedule.Hours(hours) } -private fun List.filterYears(localTime: LocalDateTime): List = when { - none { it.years.isNullOrEmpty().not() } -> this - else -> filter { - (it.years ?: return@filter false).any { +/** + * Returns only the rules that are valid for the given year. + */ +private fun List.filterYears(localTime: LocalDateTime): List { + if (all { it.years.isNullOrEmpty() }) return this + + val thisYear = filter { + it.years?.any { when (it) { is Year -> it.year == localTime.year is StartingAtYear -> it.start <= localTime.year is YearRange -> localTime.year in it.start..it.end step (it.step ?: 1) } - } - }.ifEmpty { - filter { - it.years.isNullOrEmpty() - } + } == true } + + if (!thisYear.isEmpty()) return thisYear + + return filter { it.years.isNullOrEmpty() } } -private fun List.filterMonths(localTime: LocalDateTime): List = when { - none { it.months.isNullOrEmpty().not() } -> this - else -> filter { - (it.months ?: return@filter false).any { +/** + * Returns only the rules that are valid for the given month. + */ +private fun List.filterMonths(localTime: LocalDateTime): List { + if (all { it.months.isNullOrEmpty() }) return this + + val thisMonth = filter { + it.months?.any { when (it) { is MonthRange -> (it.year?.let { it == localTime.year } != false) && localTime.month.ordinal in it.start.ordinal..it.end.ordinal @@ -417,95 +443,118 @@ private fun List.filterMonths(localTime: LocalDateTime): List = wh else -> false } - } - }.ifEmpty { - filter { - it.months.isNullOrEmpty() - } + } == true } + + if (!thisMonth.isEmpty()) return thisMonth + + return filter { it.months.isNullOrEmpty() } } -private fun List.filterNthDays(localTime: LocalDateTime): List = when { - none { it.weekdays?.any { it is SpecificWeekdays } == true } -> this - else -> localTime.getNthWeekdaysOfCurrentWeek().let { currentWeek -> +/** + * Returns only the rules that are valid for the given week. + * (i.e. if the given week is the 2nd week of the month, it will return only the rules that are + * valid for the 2nd week of the month) + */ +private fun List.filterNthDays(localTime: LocalDateTime): List { + if (none { it.weekdays?.any { it is SpecificWeekdays } == true }) return this - val specific = mapNotNull { range -> - (range.weekdays?.singleOrNull() as? SpecificWeekdays)?.let { - range to it - } + val currentWeek = localTime.getNthWeekdaysOfCurrentWeek() + + val specific = mapNotNull { range -> + (range.weekdays?.singleOrNull() as? SpecificWeekdays)?.let { + range to it } - val unspecific = single { it.weekdays?.singleOrNull() !is SpecificWeekdays } + } - specific.firstOrNull { (_, specific) -> - currentWeek.any { (dow, nthFwd, nthBwd) -> - specific.weekday.ordinal == dow.ordinal && specific.nths.any { - when (it) { - is Nth -> it.nth == nthFwd - is NthRange -> nthFwd in it.start..it.end - is LastNth -> it.nth == nthBwd - } + val rule = specific.firstOrNull { (_, specific) -> + currentWeek.any { (dow, nthFwd, nthBwd) -> + specific.weekday.ordinal == dow.ordinal && specific.nths.any { + when (it) { + is Nth -> it.nth == nthFwd + is NthRange -> nthFwd in it.start..it.end + is LastNth -> it.nth == nthBwd } } - }?.let { - (rule, _) -> listOf(rule) - } ?: listOf(unspecific) - } -} - -private fun WeekdaysSelector.toDaysOfWeek(): List = when (this) { - is Weekday -> listOf( - when (this) { - Weekday.Monday -> DayOfWeek.MONDAY - Weekday.Tuesday -> DayOfWeek.TUESDAY - Weekday.Wednesday -> DayOfWeek.WEDNESDAY - Weekday.Thursday -> DayOfWeek.THURSDAY - Weekday.Friday -> DayOfWeek.FRIDAY - Weekday.Saturday -> DayOfWeek.SATURDAY - Weekday.Sunday -> DayOfWeek.SUNDAY } + } + + if (rule != null) return listOf(rule.first) + + return listOfNotNull( + singleOrNull { it.weekdays?.singleOrNull() !is SpecificWeekdays } ) - - is WeekdayRange -> (start to end).map { it.toDaysOfWeek().single().value } - .into { start, end -> (start..end).map { DayOfWeek.of(it) } } - - is SpecificWeekdays -> weekday.toDaysOfWeek() } - -private fun TimesSelector.toLocalTimeWithDuration(): List> { +private fun WeekdaysSelector.toDaysOfWeek(): List { return when (this) { - is TimeSpan -> { - val start = start as? ClockTime ?: return emptyList() - val end = end as? ExtendedClockTime ?: return emptyList() + is Weekday -> listOf( + when (this) { + Weekday.Monday -> DayOfWeek.MONDAY + Weekday.Tuesday -> DayOfWeek.TUESDAY + Weekday.Wednesday -> DayOfWeek.WEDNESDAY + Weekday.Thursday -> DayOfWeek.THURSDAY + Weekday.Friday -> DayOfWeek.FRIDAY + Weekday.Saturday -> DayOfWeek.SATURDAY + Weekday.Sunday -> DayOfWeek.SUNDAY + } + ) - listOf( - LocalTime.of( - start.hour, - start.minutes - ) to Duration.ofMinutes((Math.floorMod(end.hour - start.hour, 24) * 60 + end.minutes - start.minutes).toLong()) - ) - } + is WeekdayRange -> (start to end).map { it.toDaysOfWeek().single().value } + .into { start, end -> (start..end).map { DayOfWeek.of(it) } } - else -> return emptyList() + is SpecificWeekdays -> weekday.toDaysOfWeek() } } + +private fun TimesSelector.toLocalTimeWithDuration(): Pair? { + if (this !is TimeSpan) return null + + val start = start as? ClockTime ?: return null + val end = end as? ExtendedClockTime ?: return null + + return LocalTime.of( + start.hour, + start.minutes + ) to Duration.ofMinutes( + (Math.floorMod( + end.hour - start.hour, + 24 + ) * 60 + end.minutes - start.minutes).toLong() + ) +} + +/** + * Calculates the ordinal position of each day of the current week (Monday to Sunday) within the month. + * + * @receiver LocalDateTime The current date and time. + * @return A list of triples, where each triple contains: + * - The `DayOfWeek` (e.g., Monday, Tuesday, etc.). + * - The forward ordinal position of the day in the month (e.g., 1st Monday, 2nd Tuesday, etc.). + * - The backward ordinal position of the day in the month (e.g., last Monday, 2nd last Tuesday, etc.). + * + * Example: + * If today is the 15th of a month (Wednesday), the function will return: + * - For Monday: `(DayOfWeek.MONDAY, 3, 2)` (3rd Monday of the month, 2nd last Monday of the month). + * - For Wednesday: `(DayOfWeek.WEDNESDAY, 3, 2)` (3rd Wednesday of the month, 2nd last Wednesday of the month). + */ private fun LocalDateTime.getNthWeekdaysOfCurrentWeek(): List> { val monday = with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) return (0 until 7).map { i -> val (nth, nthLast) = monday.plusDays(i.toLong()).let { weekday -> ( - ChronoUnit.WEEKS.between( - with(TemporalAdjusters.firstInMonth(weekday.dayOfWeek)), - weekday - ).toInt() + 1 - ) to ( - ChronoUnit.WEEKS.between( - weekday, - with(TemporalAdjusters.lastInMonth(weekday.dayOfWeek)) - ).toInt() + 1 - ) + ChronoUnit.WEEKS.between( + with(TemporalAdjusters.firstInMonth(weekday.dayOfWeek)), + weekday + ).toInt() + 1 + ) to ( + ChronoUnit.WEEKS.between( + weekday, + with(TemporalAdjusters.lastInMonth(weekday.dayOfWeek)) + ).toInt() + 1 + ) } Triple(DayOfWeek.entries[i], nth, nthLast) } -} +} \ No newline at end of file