From 36bbb3ff2e4f4dc85f4ae9e1e47d2bc0a00e9505 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 30 Jul 2026 16:18:46 +0200 Subject: [PATCH] Four bug fixes for 2.17.0 (#89, #81, #79, #77) (#101) Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/101 --- CHANGELOG.md | 23 +++++ app/proguard-rules.pro | 12 +++ .../calendula/ui/calendars/CalendarsScreen.kt | 20 +++-- .../calendula/ui/common/AccountGroups.kt | 86 +++++++++++++++++++ .../ui/common/CalendarPickerGroups.kt | 30 +++---- .../calendula/ui/filter/CalendarFilterList.kt | 11 ++- .../calendula/ui/filter/FilterUiState.kt | 10 ++- .../calendula/ui/filter/FilterViewModel.kt | 46 +++++----- .../calendula/ui/month/MonthScreen.kt | 57 ++++++++---- .../calendula/ui/month/MonthUiState.kt | 18 ++++ .../calendula/ui/settings/SettingsScreen.kt | 2 + app/src/main/res/values/strings.xml | 2 + .../calendula/ui/filter/FilterGroupingTest.kt | 48 ++++++++++- .../calendula/ui/month/LaneEventsTest.kt | 36 ++++++++ 14 files changed, 333 insertions(+), 68 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/AccountGroups.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index db008f1..aa1a5aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 unaffected. ### Fixed +- A month-grid widget stays a month grid. Since 2.16.0 a placed month widget + could redraw itself as the agenda widget a little after any change to your + events, because the release build merged the two widgets into a single class + and Android could no longer tell which of them a widget on your home screen + was ([#89]). +- The back gesture on **Settings → Views** returns to Settings instead of + leaving Settings altogether and dropping you on the calendar. Special dates + did the same ([#81]). +- The dots standing in for the events that didn't fit a day in the month view + now dim with everything else when **Dim completed events** is on. A past day + with four or more events kept its last events at full strength while the rest + faded ([#79]). +- Two accounts that happen to share a name — a Google account and a DAVx5 + account for the same address, say — are no longer merged into one group. + They were listed together in Settings → Calendars and in the drawer's filter, + which also meant the group's source icon and its "manage in app" button could + send you to the wrong app, "toggle all" spanned both accounts at once, and + collapsing one collapsed the other. Where a name really is shared, each group + now names the app it comes from ([#77]). - Search results now show an all-day event's real date. West of UTC — anywhere in the Americas, say — a search hit was dated one day early, disagreeing with the day the month, week and agenda views file the same event under ([#82]). @@ -1169,4 +1188,8 @@ automatically, with zero telemetry and no internet permission. [#75]: https://codeberg.org/jlmakiola/calendula/issues/75 [#76]: https://codeberg.org/jlmakiola/calendula/issues/76 [#78]: https://codeberg.org/jlmakiola/calendula/issues/78 +[#77]: https://codeberg.org/jlmakiola/calendula/issues/77 +[#79]: https://codeberg.org/jlmakiola/calendula/issues/79 +[#81]: https://codeberg.org/jlmakiola/calendula/issues/81 [#82]: https://codeberg.org/jlmakiola/calendula/issues/82 +[#89]: https://codeberg.org/jlmakiola/calendula/issues/89 diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 8b1a5d3..ef98b3a 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -38,3 +38,15 @@ # SessionWorker never ran, and widgets were stuck on their loading layout # (a blank spinner) in release builds. Keep every InputMerger's name + ctor. -keep class * extends androidx.work.InputMerger { (...); } + +# Glance identifies a widget by its GlanceAppWidget subclass's *canonical name*: +# GlanceAppWidgetManager persists a providerName -> receivers map under that +# string, and `updateAll` looks the widget's app-widget ids up through it. Under +# R8 full mode (AGP 9 default) MonthWidget and AgendaWidget — same supertype, +# same overrides, no distinguishing members — were horizontally merged into one +# class, so both receivers registered under the *same* provider name and +# `AgendaWidget().updateAll()` resolved the month widget's id too, redrawing a +# placed month widget as the agenda one on the next data change (#89). Keeping +# the real names also survives app updates, which would otherwise renumber the +# obfuscated name and orphan the stored mapping. +-keep class * extends androidx.glance.appwidget.GlanceAppWidget diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index b940c03..0caf61c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -99,7 +99,10 @@ import de.jeanlucmakiola.calendula.domain.isNotSynced import de.jeanlucmakiola.calendula.domain.orderedForManager import de.jeanlucmakiola.calendula.domain.stateLabels import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette +import de.jeanlucmakiola.calendula.ui.common.AccountKey import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle +import de.jeanlucmakiola.calendula.ui.common.groupByAccount import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar @@ -228,7 +231,7 @@ private fun CalendarsList( val snackbarHostState = remember { SnackbarHostState() } // Accounts the user has folded shut; empty = all expanded (keeps every // calendar visible by default, the section is collapsible for tidiness). - var collapsedAccounts by remember { mutableStateOf(emptySet()) } + var collapsedAccounts by remember { mutableStateOf(emptySet()) } var localExpanded by remember { mutableStateOf(true) } val writeErrorText = stringResource(R.string.calendars_write_error) @@ -417,10 +420,11 @@ private fun CalendarsList( SectionHeader(stringResource(R.string.calendars_synced_header)) HintText(stringResource(R.string.calendars_synced_hint)) synced - .groupBy { it.accountName.ifBlank { it.accountType } } - .forEach { (account, cals) -> - val expanded = account !in collapsedAccounts - val accountType = cals.first().accountType + .groupByAccount() + .forEach { group -> + val cals = group.calendars + val expanded = group.key !in collapsedAccounts + val accountType = group.accountType // A non-syncing calendar has no switch, so it neither counts // towards "the whole account is off" nor moves with toggle-all. val switchable = cals.filter { it.hasVisibilitySwitch } @@ -428,7 +432,7 @@ private fun CalendarsList( switchable.none { it.isVisibleInSystem } Spacer(Modifier.height(16.dp)) CalendarGroup( - title = account, + title = accountGroupTitle(group), expanded = expanded, bodyHasRows = true, headerDisabled = accountDisabled, @@ -440,9 +444,9 @@ private fun CalendarsList( }, onToggleExpand = { collapsedAccounts = if (expanded) { - collapsedAccounts + account + collapsedAccounts + group.key } else { - collapsedAccounts - account + collapsedAccounts - group.key } }, showToggleAll = switchable.isNotEmpty(), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/AccountGroups.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/AccountGroups.kt new file mode 100644 index 0000000..3240a53 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/AccountGroups.kt @@ -0,0 +1,86 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.CalendarSource + +/** + * One account's calendars, as every surface that lists calendars by account + * shows them. + * + * An account is identified by name **and** type (#77). A Google account and a + * DAVx5 account can carry the same address and still be two separate accounts, + * from separate apps: merging them mixed their calendars into one group whose + * header — source logo, "manage in app", toggle-all, collapsed state — was + * derived from whichever calendar happened to sort first. + */ +data class CalendarAccountGroup( + /** Stable identity: what makes two calendars belong to the same account. */ + val key: AccountKey, + /** The account's own name, as shown when it is unambiguous. */ + val label: String, + /** True when another group shows the same [label] under a different type. */ + val ambiguous: Boolean, + val calendars: List, +) { + val accountType: String get() = key.type +} + +/** The pair a group is keyed on. */ +data class AccountKey(val name: String, val type: String) + +/** + * Group [calendars] under their owning account, preserving the provider's order + * within each group and ordering groups by first appearance. + * + * The label falls back through name → type → the first calendar's own name, so + * a calendar with no account still lands somewhere sensible. + */ +fun List.groupByAccount(): List { + val grouped = groupBy { AccountKey(it.accountName, it.accountType) } + val labels = grouped.mapValues { (key, cals) -> + key.name.ifBlank { key.type }.ifBlank { cals.first().displayName } + } + val shared = labels.values.groupingBy { it }.eachCount() + return grouped.map { (key, cals) -> + val label = labels.getValue(key) + CalendarAccountGroup( + key = key, + label = label, + ambiguous = shared.getValue(label) > 1, + calendars = cals, + ) + } +} + +/** + * What to write above a group: its account name, qualified with the app the + * account comes from when another account shares the name (#77). + */ +@Composable +fun accountGroupTitle(group: CalendarAccountGroup): String = + if (!group.ambiguous) { + group.label + } else { + stringResource(R.string.calendars_account_from_source, group.label, sourceAppName(group.accountType)) + } + +/** + * The human name of the app backing [accountType] — the same app whose icon + * [SourceLogo] draws. Falls back to the raw account type, which is at least + * unique, when no installed app resolves for it. + */ +@Composable +fun sourceAppName(accountType: String): String { + val context = LocalContext.current + return remember(accountType) { + val pm = context.packageManager + val packages = sourceAppPackages(context, accountType) + packages.firstNotNullOfOrNull { pkg -> + runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull() + } ?: accountType + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt index 4d00227..92285c5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt @@ -60,9 +60,7 @@ fun ColumnScope.CalendarPickerGroups( ) { val local = remember(calendars) { calendars.filter { it.isLocal } } val syncedGroups = remember(calendars) { - calendars.filterNot { it.isLocal } - .groupBy { it.accountName.ifBlank { it.accountType }.ifBlank { it.displayName } } - .toList() + calendars.filterNot { it.isLocal }.groupByAccount() } if (local.isNotEmpty()) { @@ -74,12 +72,12 @@ fun ColumnScope.CalendarPickerGroups( onSelect = onSelect, ) } - syncedGroups.forEachIndexed { index, (account, cals) -> + syncedGroups.forEachIndexed { index, group -> if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp)) CalendarPickerGroup( - title = account, - leading = { SourceLogo(cals.first().accountType) }, - calendars = cals, + title = accountGroupTitle(group), + leading = { SourceLogo(group.accountType) }, + calendars = group.calendars, selectedId = selectedId, onSelect = onSelect, ) @@ -181,20 +179,22 @@ fun LeadingAvatar(icon: ImageVector) { /** The launcher icon of the app backing [accountType], preferring the human-facing app. */ private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? { val pm = context.packageManager - val candidates = buildList { - curatedSourcePackage(accountType)?.let { add(it) } - AccountManager.get(context).authenticatorTypes - .firstOrNull { it.type.equals(accountType, ignoreCase = true) } - ?.packageName - ?.let { add(it) } - } - for (pkg in candidates) { + for (pkg in sourceAppPackages(context, accountType)) { val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull() if (bitmap != null) return bitmap.asImageBitmap() } return null } +/** Apps that could stand for [accountType], best candidate first. */ +internal fun sourceAppPackages(context: Context, accountType: String): List = buildList { + curatedSourcePackage(accountType)?.let { add(it) } + AccountManager.get(context).authenticatorTypes + .firstOrNull { it.type.equals(accountType, ignoreCase = true) } + ?.packageName + ?.let { add(it) } +} + /** Preferred app for account types whose authenticator isn't the app to open. */ internal fun curatedSourcePackage(accountType: String): String? = when { accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar" diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/CalendarFilterList.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/CalendarFilterList.kt index 0827abd..8070119 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/CalendarFilterList.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/CalendarFilterList.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.sourceAppName import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.positionOf @@ -61,7 +62,15 @@ private fun FilterList( Column(modifier = modifier.fillMaxWidth()) { groups.forEach { group -> Text( - text = group.account, + text = if (group.ambiguous) { + stringResource( + R.string.calendars_account_from_source, + group.account, + sourceAppName(group.accountType), + ) + } else { + group.account + }, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterUiState.kt index 2fe8ebb..ac693f5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterUiState.kt @@ -13,9 +13,17 @@ sealed interface FilterUiState { data class Success(val groups: List) : FilterUiState } -/** Calendars grouped under the account that owns them (Nextcloud / Local / …). */ +/** + * Calendars grouped under the account that owns them (Nextcloud / Local / …). + * + * [accountType] and [ambiguous] carry what the header needs to tell two + * same-named accounts from different apps apart (#77); the label itself is + * built in the UI layer, which is where the source app's name can be looked up. + */ data class AccountGroup( val account: String, + val accountType: String, + val ambiguous: Boolean, val calendars: List, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterViewModel.kt index 66062b0..bdcf193 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/filter/FilterViewModel.kt @@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.FailureReason +import de.jeanlucmakiola.calendula.ui.common.groupByAccount import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -38,7 +39,7 @@ class FilterViewModel @Inject constructor( if (enabled.isEmpty()) { FilterUiState.Failure(FailureReason.NoCalendarsConfigured) } else { - FilterUiState.Success(groupByAccount(enabled, hidden)) + FilterUiState.Success(groupCalendarsForFilter(enabled, hidden)) } } .catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) } @@ -60,30 +61,27 @@ class FilterViewModel @Inject constructor( } /** - * Group calendars under their owning account, preserving the provider's order - * within each group and ordering groups by first appearance. A calendar is - * "visible" when its id is *not* in [hidden]. + * Group calendars under their owning account — by name *and* type, so two + * accounts that merely share a name stay apart (#77) — preserving the + * provider's order within each group and ordering groups by first appearance. + * A calendar is "visible" when its id is *not* in [hidden]. */ -internal fun groupByAccount( +internal fun groupCalendarsForFilter( calendars: List, hidden: Set, ): List = - calendars - .groupBy { it.accountLabel() } - .map { (account, cals) -> - AccountGroup( - account = account, - calendars = cals.map { c -> - CalendarRow( - id = c.id, - displayName = c.displayName, - color = c.color, - visible = c.id !in hidden, - ) - }, - ) - } - -/** Account header text: the account name, falling back to its type. */ -private fun CalendarSource.accountLabel(): String = - accountName.takeIf { it.isNotBlank() } ?: accountType.takeIf { it.isNotBlank() } ?: displayName + calendars.groupByAccount().map { group -> + AccountGroup( + account = group.label, + accountType = group.accountType, + ambiguous = group.ambiguous, + calendars = group.calendars.map { c -> + CalendarRow( + id = c.id, + displayName = c.displayName, + color = c.color, + visible = c.id !in hidden, + ) + }, + ) + } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 41a4d8f..0bd8290 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -137,6 +137,7 @@ import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toLocalDateTime import kotlin.math.abs import kotlin.time.Clock +import kotlin.time.Instant import java.time.format.TextStyle as JavaTextStyle import java.util.Locale @@ -1277,7 +1278,7 @@ internal fun SplitMonthGrid( SplitDayCell( date = day, events = seated, - hidden = (week.countByDay[day] ?: 0) - seated.size, + hidden = week.overflowEvents(col, day, MAX_EVENT_ROWS), isToday = day == state.today, // A page marks only the days its own month owns. Paging // moves the selection before this month's replacement @@ -1318,7 +1319,7 @@ private fun SplitDayCell( date: LocalDate, events: List, /** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */ - hidden: Int, + hidden: List, isToday: Boolean, isSelected: Boolean, inMonth: Boolean, @@ -1436,9 +1437,15 @@ private fun SplitDayCell( * bar with no dot to grow out of. */ @Composable -private fun SplitDots(date: LocalDate, events: List, hidden: Int, dark: Boolean) { +private fun SplitDots( + date: LocalDate, + events: List, + hidden: List, + dark: Boolean, +) { if (events.isEmpty()) return val soften = LocalSoftenColors.current + val dimCutoff = LocalDimCutoff.current Row( horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, @@ -1461,18 +1468,21 @@ private fun SplitDots(date: LocalDate, events: List, hidden: Int, modifier = Modifier .morphBounds(MonthMorphKey.Event(date, event.instanceId)) .size(SPLIT_DOT_SIZE) + .alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f) .background(eventFill(event.color, dark, soften), CircleShape), ) } - if (hidden > 0) { + if (hidden.isNotEmpty()) { // Tagged, not lifted: this count and the expanded grid's dot row are // the same marker on the same day, so it travels with its cell like // everything else rather than riding above the grid on its own layer. Text( - text = "+$hidden", + text = "+${hidden.size}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.morphBounds(MonthMorphKey.Overflow(date)), + modifier = Modifier + .morphBounds(MonthMorphKey.Overflow(date)) + .alpha(if (allEnded(hidden, dimCutoff)) EventDimAlpha else 1f), ) } } @@ -1870,15 +1880,15 @@ private fun MonthWeekRow( } val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size if (hidden > 0) { - val hiddenColors = buildList { + val hiddenEvents = buildList { week.spans .filter { it.lane >= shownLanes && col in it.startCol..it.endCol } - .forEach { add(it.event.color) } - timed.drop(pillsShown.size).forEach { add(it.color) } - }.distinct().take(3) + .forEach { add(it.event) } + addAll(timed.drop(pillsShown.size)) + } OverflowDots( - colors = hiddenColors, - extra = hidden - hiddenColors.size, + events = hiddenEvents, + total = hidden, dark = dark, modifier = Modifier .offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS) @@ -2058,37 +2068,52 @@ private fun MonthBar( } } -/** Overflow row: a dot per hidden event (up to three) plus "+N" for the rest. */ +/** + * Overflow row: a dot per hidden colour (up to three) plus "+N" for the rest. + * + * A dot stands for every hidden event sharing its colour, so it dims only once + * all of them have ended; the "+N" dims once the whole overflow has (#79). + */ @Composable private fun OverflowDots( - colors: List, - extra: Int, + events: List, + total: Int, dark: Boolean, modifier: Modifier = Modifier, ) { val soften = LocalSoftenColors.current + val dimCutoff = LocalDimCutoff.current + val byColor = events.groupBy { it.color } + val dots = byColor.keys.take(3) Row( modifier = modifier.height(EVENT_ROW_HEIGHT), horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, ) { - colors.forEach { argb -> + dots.forEach { argb -> Box( modifier = Modifier .size(6.dp) + .alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f) .background(eventFill(argb, dark, soften), CircleShape), ) } + val extra = total - dots.size if (extra > 0) { Text( text = "+$extra", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.alpha(if (allEnded(events, dimCutoff)) EventDimAlpha else 1f), ) } } } +/** True when dimming is on and every one of [events] is already over. */ +private fun allEnded(events: List, dimCutoff: Instant?): Boolean = + dimCutoff != null && events.isNotEmpty() && events.all { it.hasEnded(dimCutoff) } + @Composable private fun MonthGridLoading() { val shape = MaterialTheme.shapes.medium diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt index 3b1bc80..b7f5f6a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt @@ -67,6 +67,24 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List { + val seatedLanes = spans.count { it.lane < laneCap && col in it.startCol..it.endCol } + return buildList { + spans.forEach { span -> + if (span.lane >= laneCap && col in span.startCol..span.endCol) add(span.event) + } + addAll(timedByDay[day].orEmpty().drop(laneCap - seatedLanes)) + } +} + /** * State for the continuous style (#38): a vertical stream of *self-contained* * months rather than one undifferentiated run of weeks. Each month is keyed by diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index c1efe32..699c08b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -862,6 +862,7 @@ private fun ViewsScreen( CollapsingScaffold( title = stringResource(R.string.settings_section_views), onBack = onBack, + predictiveBack = true, ) { val config = state.quickSwitchConfig @@ -1403,6 +1404,7 @@ private fun SpecialDatesScreen( CollapsingScaffold( title = stringResource(R.string.settings_section_special_dates), onBack = onBack, + predictiveBack = true, ) { // Paused banner: the permission was revoked after enabling. if (state.enabled && state.stalledPermission) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 21c16fc..18fe3ab 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -488,6 +488,8 @@ Synced calendars These come from accounts on your device. Create and edit them in their own app. Manage in app + + %1$s (%2$s) More options for %1$s Enable all Disable all diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/filter/FilterGroupingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/filter/FilterGroupingTest.kt index 123ab8b..7900f72 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/filter/FilterGroupingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/filter/FilterGroupingTest.kt @@ -28,7 +28,7 @@ class FilterGroupingTest { cal(3, "Shared", "team@dav"), ) - val groups = groupByAccount(calendars, hidden = emptySet()) + val groups = groupCalendarsForFilter(calendars, hidden = emptySet()) assertThat(groups.map { it.account }).containsExactly("alice@dav", "team@dav").inOrder() assertThat(groups[0].calendars.map { it.displayName }) @@ -43,7 +43,7 @@ class FilterGroupingTest { cal(2, "Work", "alice@dav"), ) - val groups = groupByAccount(calendars, hidden = setOf(2L)) + val groups = groupCalendarsForFilter(calendars, hidden = setOf(2L)) val rows = groups.single().calendars.associateBy { it.id } assertThat(rows.getValue(1L).visible).isTrue() @@ -52,10 +52,52 @@ class FilterGroupingTest { @Test fun `blank account name falls back to type`() { - val groups = groupByAccount( + val groups = groupCalendarsForFilter( listOf(cal(1, "Birthdays", account = "", type = "LOCAL")), hidden = emptySet(), ) assertThat(groups.single().account).isEqualTo("LOCAL") } + + /** #77: same address, two apps — two accounts, and they must read as two. */ + @Test + fun `one name under two account types stays two groups`() { + val calendars = listOf( + cal(1, "Personal", "me@example.com", type = "com.google"), + cal(2, "Shared", "me@example.com", type = "bitfire.at.davdroid"), + ) + + val groups = groupCalendarsForFilter(calendars, hidden = emptySet()) + + assertThat(groups).hasSize(2) + assertThat(groups.map { it.accountType }) + .containsExactly("com.google", "bitfire.at.davdroid").inOrder() + assertThat(groups.map { it.calendars.single().id }).containsExactly(1L, 2L).inOrder() + assertThat(groups.all { it.ambiguous }).isTrue() + } + + @Test + fun `one name under one type is not ambiguous`() { + val groups = groupCalendarsForFilter( + listOf(cal(1, "Personal", "me@example.com")), + hidden = emptySet(), + ) + + assertThat(groups.single().ambiguous).isFalse() + } + + /** Two different names from the same app are still two plain groups. */ + @Test + fun `different names under one type are not ambiguous`() { + val groups = groupCalendarsForFilter( + listOf( + cal(1, "Personal", "alice@example.com"), + cal(2, "Shared", "bob@example.com"), + ), + hidden = emptySet(), + ) + + assertThat(groups).hasSize(2) + assertThat(groups.none { it.ambiguous }).isTrue() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt index 0374ab3..a64ff01 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt @@ -122,6 +122,42 @@ class LaneEventsTest { .containsNoneIn(seated) } + @Test + fun `overflow is exactly what seating left behind`() { + val events = (0 until 5).map { timed(LocalDate(2026, 7, 6), hour = 8 + it, id = it + 1L) } + val week = rowOfJuly6(events) + val day = LocalDate(2026, 7, 6) + + val seated = week.laneEvents(col = 0, day = day, laneCap = 3) + val overflow = week.overflowEvents(col = 0, day = day, laneCap = 3) + + assertThat(overflow).containsExactlyElementsIn(events.drop(3)).inOrder() + assertThat(seated + overflow).containsExactlyElementsIn(events) + assertThat(seated.size + overflow.size).isEqualTo(week.countByDay[day]) + } + + @Test + fun `a bar beyond the cap overflows on every day it covers`() { + val bars = (0 until 4).map { + allDay(LocalDate(2026, 7, 6), LocalDate(2026, 7, 8), id = it + 1L) + } + val week = rowOfJuly6(bars) + val parked = week.spans.filter { it.lane >= 3 }.map { it.event } + + (0..2).forEach { col -> + val day = LocalDate(2026, 7, 6 + col) + assertThat(week.overflowEvents(col, day, laneCap = 3)) + .containsExactlyElementsIn(parked) + } + } + + @Test + fun `a day that fits has no overflow`() { + val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 6), hour = 9, id = 1L))) + + assertThat(week.overflowEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)).isEmpty() + } + private companion object { const val BLUE = 0xFF3366CC.toInt() const val RED = 0xFFCC3333.toInt()