Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/101
This commit is contained in:
23
CHANGELOG.md
23
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
12
app/proguard-rules.pro
vendored
12
app/proguard-rules.pro
vendored
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user