Compare commits

...

4 Commits

Author SHA1 Message Date
05b64804bb 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.
2026-07-30 15:23:33 +02:00
34f23ee14e fix(month): dim the overflow dots of a completed day too (#79)
"Dim completed events" faded a day's bars and pills but never the dots standing
for the events that didn't fit it, so a past day with four or more events kept
its "+N" and its dots at full strength.

Both dot renderers only ever received colours and a count, which is not enough
to ask whether an event has ended. They now take the events themselves:
overflowEvents() is the documented complement of laneEvents(), so the split
grid's "+N" knows what it stands for, and the paged grid passes its hidden
events instead of their colours. A dot covers every hidden event sharing its
colour, so it dims once all of them are over; the "+N" dims once the whole
overflow is.
2026-07-30 15:23:24 +02:00
b4c4127bff fix(settings): let back leave a settings sub-screen, not all of Settings (#81)
The settings sub-screens are overlays over the hub, so each has to take the back
gesture itself; the hub's handler pops the whole Settings destination. Views and
Special dates never passed predictiveBack, so back there fell through to the hub
and dropped you on the calendar. Appearance, Event form and Notifications
already did the right thing.
2026-07-30 15:23:24 +02:00
6a2f490b1a fix(widget): keep the two widgets apart in release builds (#89)
Glance identifies a widget by its GlanceAppWidget subclass's canonical name:
GlanceAppWidgetManager stores a providerName -> receivers map under that string,
and updateAll resolves a widget's app-widget ids through it. R8 full mode
horizontally merged MonthWidget into AgendaWidget — same supertype, same
overrides, nothing to tell them apart — so both receivers registered under one
provider name and AgendaWidget().updateAll() also matched the month widget's id,
redrawing it as the agenda widget on the next PROVIDER_CHANGED.

2.16.0 is where it started because that release gave AgendaWidget the
SizeMode.Exact override MonthWidget already had, which is what made the two
classes mergeable. Verified in the releaseTest mapping: before, only AgendaWidget
survived, carrying an $r8$classId field and MonthWidget's constructor frames;
after, both classes are there under their own names.

Keeping the real names also survives app updates, which would otherwise renumber
the obfuscated name and orphan the stored mapping.
2026-07-30 15:23:11 +02:00
14 changed files with 333 additions and 68 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

@@ -38,3 +38,15 @@
# SessionWorker never ran, and widgets were stuck on their loading layout # SessionWorker never ran, and widgets were stuck on their loading layout
# (a blank spinner) in release builds. Keep every InputMerger's name + ctor. # (a blank spinner) in release builds. Keep every InputMerger's name + ctor.
-keep class * extends androidx.work.InputMerger { <init>(...); } -keep class * extends androidx.work.InputMerger { <init>(...); }
# 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

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

@@ -137,6 +137,7 @@ import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.math.abs import kotlin.math.abs
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale import java.util.Locale
@@ -1277,7 +1278,7 @@ internal fun SplitMonthGrid(
SplitDayCell( SplitDayCell(
date = day, date = day,
events = seated, events = seated,
hidden = (week.countByDay[day] ?: 0) - seated.size, hidden = week.overflowEvents(col, day, MAX_EVENT_ROWS),
isToday = day == state.today, isToday = day == state.today,
// A page marks only the days its own month owns. Paging // A page marks only the days its own month owns. Paging
// moves the selection before this month's replacement // moves the selection before this month's replacement
@@ -1318,7 +1319,7 @@ private fun SplitDayCell(
date: LocalDate, date: LocalDate,
events: List<EventInstance>, events: List<EventInstance>,
/** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */ /** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */
hidden: Int, hidden: List<EventInstance>,
isToday: Boolean, isToday: Boolean,
isSelected: Boolean, isSelected: Boolean,
inMonth: Boolean, inMonth: Boolean,
@@ -1436,9 +1437,15 @@ private fun SplitDayCell(
* bar with no dot to grow out of. * bar with no dot to grow out of.
*/ */
@Composable @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 if (events.isEmpty()) return
val soften = LocalSoftenColors.current val soften = LocalSoftenColors.current
val dimCutoff = LocalDimCutoff.current
Row( Row(
horizontalArrangement = Arrangement.spacedBy(2.dp), horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -1461,18 +1468,21 @@ private fun SplitDots(date: LocalDate, events: List<EventInstance>, hidden: Int,
modifier = Modifier modifier = Modifier
.morphBounds(MonthMorphKey.Event(date, event.instanceId)) .morphBounds(MonthMorphKey.Event(date, event.instanceId))
.size(SPLIT_DOT_SIZE) .size(SPLIT_DOT_SIZE)
.alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f)
.background(eventFill(event.color, dark, soften), CircleShape), .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 // 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 // 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. // everything else rather than riding above the grid on its own layer.
Text( Text(
text = "+$hidden", text = "+${hidden.size}",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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 val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
if (hidden > 0) { if (hidden > 0) {
val hiddenColors = buildList { val hiddenEvents = buildList {
week.spans week.spans
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol } .filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
.forEach { add(it.event.color) } .forEach { add(it.event) }
timed.drop(pillsShown.size).forEach { add(it.color) } addAll(timed.drop(pillsShown.size))
}.distinct().take(3) }
OverflowDots( OverflowDots(
colors = hiddenColors, events = hiddenEvents,
extra = hidden - hiddenColors.size, total = hidden,
dark = dark, dark = dark,
modifier = Modifier modifier = Modifier
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS) .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 @Composable
private fun OverflowDots( private fun OverflowDots(
colors: List<Int>, events: List<EventInstance>,
extra: Int, total: Int,
dark: Boolean, dark: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val soften = LocalSoftenColors.current val soften = LocalSoftenColors.current
val dimCutoff = LocalDimCutoff.current
val byColor = events.groupBy { it.color }
val dots = byColor.keys.take(3)
Row( Row(
modifier = modifier.height(EVENT_ROW_HEIGHT), modifier = modifier.height(EVENT_ROW_HEIGHT),
horizontalArrangement = Arrangement.spacedBy(2.dp), horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
colors.forEach { argb -> dots.forEach { argb ->
Box( Box(
modifier = Modifier modifier = Modifier
.size(6.dp) .size(6.dp)
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
.background(eventFill(argb, dark, soften), CircleShape), .background(eventFill(argb, dark, soften), CircleShape),
) )
} }
val extra = total - dots.size
if (extra > 0) { if (extra > 0) {
Text( Text(
text = "+$extra", text = "+$extra",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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 @Composable
private fun MonthGridLoading() { private fun MonthGridLoading() {
val shape = MaterialTheme.shapes.medium 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() 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* * 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 * months rather than one undifferentiated run of weeks. Each month is keyed by

View File

@@ -862,6 +862,7 @@ private fun ViewsScreen(
CollapsingScaffold( CollapsingScaffold(
title = stringResource(R.string.settings_section_views), title = stringResource(R.string.settings_section_views),
onBack = onBack, onBack = onBack,
predictiveBack = true,
) { ) {
val config = state.quickSwitchConfig val config = state.quickSwitchConfig
@@ -1403,6 +1404,7 @@ private fun SpecialDatesScreen(
CollapsingScaffold( CollapsingScaffold(
title = stringResource(R.string.settings_section_special_dates), title = stringResource(R.string.settings_section_special_dates),
onBack = onBack, onBack = onBack,
predictiveBack = true,
) { ) {
// Paused banner: the permission was revoked after enabling. // Paused banner: the permission was revoked after enabling.
if (state.enabled && state.stalledPermission) { if (state.enabled && state.stalledPermission) {

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()
}
} }

View File

@@ -122,6 +122,42 @@ class LaneEventsTest {
.containsNoneIn(seated) .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 { private companion object {
const val BLUE = 0xFF3366CC.toInt() const val BLUE = 0xFF3366CC.toInt()
const val RED = 0xFFCC3333.toInt() const val RED = 0xFFCC3333.toInt()