Four bug fixes for 2.17.0 (#89, #81, #79, #77) (#101)

Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/101
This commit is contained in:
Jean-Luc Makiola
2026-07-30 16:18:46 +02:00
parent 1d07b64a28
commit 36bbb3ff2e
14 changed files with 333 additions and 68 deletions

View File

@@ -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<String>()) }
var collapsedAccounts by remember { mutableStateOf(emptySet<AccountKey>()) }
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(),

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 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<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. */
internal fun curatedSourcePackage(accountType: String): String? = when {
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.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),

View File

@@ -13,9 +13,17 @@ sealed interface 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(
val account: String,
val accountType: String,
val ambiguous: Boolean,
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.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<CalendarSource>,
hidden: Set<Long>,
): List<AccountGroup> =
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,
)
},
)
}

View File

@@ -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<EventInstance>,
/** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */
hidden: Int,
hidden: List<EventInstance>,
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<EventInstance>, hidden: Int, dark: Boolean) {
private fun SplitDots(
date: LocalDate,
events: List<EventInstance>,
hidden: List<EventInstance>,
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<EventInstance>, 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<Int>,
extra: Int,
events: List<EventInstance>,
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<EventInstance>, dimCutoff: Instant?): Boolean =
dimCutoff != null && events.isNotEmpty() && events.all { it.hasEnded(dimCutoff) }
@Composable
private fun MonthGridLoading() {
val shape = MaterialTheme.shapes.medium

View File

@@ -67,6 +67,24 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
return byLane.filterNotNull()
}
/**
* The events on [day] that [laneEvents] had no lane left for — the exact
* complement of what it seats, in the same bars-then-pills order. Together the
* two partition the day, so their sizes add up to [countByDay].
*
* The "+N" marker needs the events themselves, not just how many there are:
* dimming a completed event is a per-event question (#79).
*/
fun MonthWeek.overflowEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
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

View File

@@ -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) {

View File

@@ -488,6 +488,8 @@
<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_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_enable_all">Enable all</string>
<string name="calendars_disable_all">Disable all</string>