fix(calendars): tell two same-named accounts apart (#77)

Calendars were grouped by account name alone, so a Google account and a DAVx5
account for the same address became one group. That is more than a mis-grouping:
the header's source logo and "manage in app" came off cals.first(), so it could
send you to the wrong app; "toggle all" spanned both accounts; and the collapsed
state, keyed on the name, folded both at once.

Grouping now keys on (name, type), in one shared place — Settings → Calendars,
the calendar picker and the drawer's filter each had their own copy with
slightly different fallbacks. The header takes its logo and manage action from
its own group's type, and the collapse set holds the key rather than the name.
Where a name really is shared, each group names the app it comes from, resolved
the same way the source icon is.
This commit is contained in:
2026-07-30 15:23:33 +02:00
parent 34f23ee14e
commit 05b64804bb
9 changed files with 224 additions and 52 deletions

View File

@@ -37,6 +37,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
unaffected. unaffected.
### Fixed ### 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 - 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 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]). 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 [#75]: https://codeberg.org/jlmakiola/calendula/issues/75
[#76]: https://codeberg.org/jlmakiola/calendula/issues/76 [#76]: https://codeberg.org/jlmakiola/calendula/issues/76
[#78]: https://codeberg.org/jlmakiola/calendula/issues/78 [#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 [#82]: https://codeberg.org/jlmakiola/calendula/issues/82
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89

View File

@@ -99,7 +99,10 @@ import de.jeanlucmakiola.calendula.domain.isNotSynced
import de.jeanlucmakiola.calendula.domain.orderedForManager import de.jeanlucmakiola.calendula.domain.orderedForManager
import de.jeanlucmakiola.calendula.domain.stateLabels import de.jeanlucmakiola.calendula.domain.stateLabels
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette 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.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.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
@@ -228,7 +231,7 @@ private fun CalendarsList(
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
// Accounts the user has folded shut; empty = all expanded (keeps every // Accounts the user has folded shut; empty = all expanded (keeps every
// calendar visible by default, the section is collapsible for tidiness). // calendar visible by default, the section is collapsible for tidiness).
var collapsedAccounts by remember { mutableStateOf(emptySet<String>()) } var collapsedAccounts by remember { mutableStateOf(emptySet<AccountKey>()) }
var localExpanded by remember { mutableStateOf(true) } var localExpanded by remember { mutableStateOf(true) }
val writeErrorText = stringResource(R.string.calendars_write_error) val writeErrorText = stringResource(R.string.calendars_write_error)
@@ -417,10 +420,11 @@ private fun CalendarsList(
SectionHeader(stringResource(R.string.calendars_synced_header)) SectionHeader(stringResource(R.string.calendars_synced_header))
HintText(stringResource(R.string.calendars_synced_hint)) HintText(stringResource(R.string.calendars_synced_hint))
synced synced
.groupBy { it.accountName.ifBlank { it.accountType } } .groupByAccount()
.forEach { (account, cals) -> .forEach { group ->
val expanded = account !in collapsedAccounts val cals = group.calendars
val accountType = cals.first().accountType val expanded = group.key !in collapsedAccounts
val accountType = group.accountType
// A non-syncing calendar has no switch, so it neither counts // A non-syncing calendar has no switch, so it neither counts
// towards "the whole account is off" nor moves with toggle-all. // towards "the whole account is off" nor moves with toggle-all.
val switchable = cals.filter { it.hasVisibilitySwitch } val switchable = cals.filter { it.hasVisibilitySwitch }
@@ -428,7 +432,7 @@ private fun CalendarsList(
switchable.none { it.isVisibleInSystem } switchable.none { it.isVisibleInSystem }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
CalendarGroup( CalendarGroup(
title = account, title = accountGroupTitle(group),
expanded = expanded, expanded = expanded,
bodyHasRows = true, bodyHasRows = true,
headerDisabled = accountDisabled, headerDisabled = accountDisabled,
@@ -440,9 +444,9 @@ private fun CalendarsList(
}, },
onToggleExpand = { onToggleExpand = {
collapsedAccounts = if (expanded) { collapsedAccounts = if (expanded) {
collapsedAccounts + account collapsedAccounts + group.key
} else { } else {
collapsedAccounts - account collapsedAccounts - group.key
} }
}, },
showToggleAll = switchable.isNotEmpty(), showToggleAll = switchable.isNotEmpty(),

View File

@@ -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<CalendarSource>,
) {
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<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
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
}
}

View File

@@ -60,9 +60,7 @@ fun ColumnScope.CalendarPickerGroups(
) { ) {
val local = remember(calendars) { calendars.filter { it.isLocal } } val local = remember(calendars) { calendars.filter { it.isLocal } }
val syncedGroups = remember(calendars) { val syncedGroups = remember(calendars) {
calendars.filterNot { it.isLocal } calendars.filterNot { it.isLocal }.groupByAccount()
.groupBy { it.accountName.ifBlank { it.accountType }.ifBlank { it.displayName } }
.toList()
} }
if (local.isNotEmpty()) { if (local.isNotEmpty()) {
@@ -74,12 +72,12 @@ fun ColumnScope.CalendarPickerGroups(
onSelect = onSelect, onSelect = onSelect,
) )
} }
syncedGroups.forEachIndexed { index, (account, cals) -> syncedGroups.forEachIndexed { index, group ->
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp)) if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
CalendarPickerGroup( CalendarPickerGroup(
title = account, title = accountGroupTitle(group),
leading = { SourceLogo(cals.first().accountType) }, leading = { SourceLogo(group.accountType) },
calendars = cals, calendars = group.calendars,
selectedId = selectedId, selectedId = selectedId,
onSelect = onSelect, onSelect = onSelect,
) )
@@ -181,20 +179,22 @@ fun LeadingAvatar(icon: ImageVector) {
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */ /** The launcher icon of the app backing [accountType], preferring the human-facing app. */
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? { private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
val pm = context.packageManager val pm = context.packageManager
val candidates = buildList { for (pkg in sourceAppPackages(context, accountType)) {
curatedSourcePackage(accountType)?.let { add(it) }
AccountManager.get(context).authenticatorTypes
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
?.packageName
?.let { add(it) }
}
for (pkg in candidates) {
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull() val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
if (bitmap != null) return bitmap.asImageBitmap() if (bitmap != null) return bitmap.asImageBitmap()
} }
return null return null
} }
/** Apps that could stand for [accountType], best candidate first. */
internal fun sourceAppPackages(context: Context, accountType: String): List<String> = 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. */ /** Preferred app for account types whose authenticator isn't the app to open. */
internal fun curatedSourcePackage(accountType: String): String? = when { internal fun curatedSourcePackage(accountType: String): String? = when {
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar" accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"

View File

@@ -21,6 +21,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip 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.GroupedRow
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
@@ -61,7 +62,15 @@ private fun FilterList(
Column(modifier = modifier.fillMaxWidth()) { Column(modifier = modifier.fillMaxWidth()) {
groups.forEach { group -> groups.forEach { group ->
Text( 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, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp), modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),

View File

@@ -13,9 +13,17 @@ sealed interface FilterUiState {
data class Success(val groups: List<AccountGroup>) : FilterUiState data class Success(val groups: List<AccountGroup>) : 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( data class AccountGroup(
val account: String, val account: String,
val accountType: String,
val ambiguous: Boolean,
val calendars: List<CalendarRow>, val calendars: List<CalendarRow>,
) )

View File

@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -38,7 +39,7 @@ class FilterViewModel @Inject constructor(
if (enabled.isEmpty()) { if (enabled.isEmpty()) {
FilterUiState.Failure(FailureReason.NoCalendarsConfigured) FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
} else { } else {
FilterUiState.Success(groupByAccount(enabled, hidden)) FilterUiState.Success(groupCalendarsForFilter(enabled, hidden))
} }
} }
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) } .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 * Group calendars under their owning account — by name *and* type, so two
* within each group and ordering groups by first appearance. A calendar is * accounts that merely share a name stay apart (#77) — preserving the
* "visible" when its id is *not* in [hidden]. * 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<CalendarSource>, calendars: List<CalendarSource>,
hidden: Set<Long>, hidden: Set<Long>,
): List<AccountGroup> = ): List<AccountGroup> =
calendars calendars.groupByAccount().map { group ->
.groupBy { it.accountLabel() } AccountGroup(
.map { (account, cals) -> account = group.label,
AccountGroup( accountType = group.accountType,
account = account, ambiguous = group.ambiguous,
calendars = cals.map { c -> calendars = group.calendars.map { c ->
CalendarRow( CalendarRow(
id = c.id, id = c.id,
displayName = c.displayName, displayName = c.displayName,
color = c.color, color = c.color,
visible = c.id !in hidden, 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

View File

@@ -488,6 +488,8 @@
<string name="calendars_synced_header">Synced calendars</string> <string name="calendars_synced_header">Synced calendars</string>
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string> <string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
<string name="calendars_manage_in_app">Manage in app</string> <string name="calendars_manage_in_app">Manage in app</string>
<!-- Account header when two accounts share a name: %1$s is the account, %2$s the app it comes from. -->
<string name="calendars_account_from_source">%1$s (%2$s)</string>
<string name="calendars_account_menu_a11y">More options for %1$s</string> <string name="calendars_account_menu_a11y">More options for %1$s</string>
<string name="calendars_enable_all">Enable all</string> <string name="calendars_enable_all">Enable all</string>
<string name="calendars_disable_all">Disable all</string> <string name="calendars_disable_all">Disable all</string>

View File

@@ -28,7 +28,7 @@ class FilterGroupingTest {
cal(3, "Shared", "team@dav"), 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.map { it.account }).containsExactly("alice@dav", "team@dav").inOrder()
assertThat(groups[0].calendars.map { it.displayName }) assertThat(groups[0].calendars.map { it.displayName })
@@ -43,7 +43,7 @@ class FilterGroupingTest {
cal(2, "Work", "alice@dav"), 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 } val rows = groups.single().calendars.associateBy { it.id }
assertThat(rows.getValue(1L).visible).isTrue() assertThat(rows.getValue(1L).visible).isTrue()
@@ -52,10 +52,52 @@ class FilterGroupingTest {
@Test @Test
fun `blank account name falls back to type`() { fun `blank account name falls back to type`() {
val groups = groupByAccount( val groups = groupCalendarsForFilter(
listOf(cal(1, "Birthdays", account = "", type = "LOCAL")), listOf(cal(1, "Birthdays", account = "", type = "LOCAL")),
hidden = emptySet(), hidden = emptySet(),
) )
assertThat(groups.single().account).isEqualTo("LOCAL") 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()
}
} }