From 4a11c951ae2044dc5824a15d4335dd29753cbc3c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 18:23:39 +0200 Subject: [PATCH] refactor: one shared calendar picker with settings-page category headers Extract CalendarPickerGroups into ui/common: the calendar-manager screen's grouped-card system (device chip for local calendars, the owning app's launcher icon per synced account, colour chip + check per calendar) as a single reusable picker. Use it in both the event editor and the .ics import screen so all 'which calendar' lists match. Moves LeadingAvatar/SourceLogo/curatedSourcePackage out of CalendarsScreen into common as the shared source of truth. Drops the redundant 'Add to calendar' caption from the import picker. Co-Authored-By: Claude Opus 4.8 --- .../calendula/ui/calendars/CalendarsScreen.kt | 69 +------ .../ui/common/CalendarPickerGroups.kt | 180 ++++++++++++++++++ .../calendula/ui/edit/EventEditScreen.kt | 47 +---- .../calendula/ui/imports/ImportScreen.kt | 57 +----- floret-kit | 1 + 5 files changed, 196 insertions(+), 158 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt create mode 160000 floret-kit diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index 3d82152..06b214e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -97,6 +97,9 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar +import de.jeanlucmakiola.calendula.ui.common.SourceLogo +import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage import de.jeanlucmakiola.calendula.ui.common.DialogAmountField import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit @@ -771,66 +774,6 @@ private fun CalendarGroupMenu( } } -/** - * The source app's launcher icon (Google Calendar, DAVx5, Nextcloud, …) as a - * round 40dp chip, so each synced account is recognisable at a glance. We load - * whatever app owns the account from [PackageManager] rather than bundling brand - * logos — always accurate, nothing to license. Falls back to a neutral cloud - * chip when no installed app resolves for the account. - */ -@Composable -private fun SourceLogo(accountType: String) { - val context = LocalContext.current - val logo = remember(accountType) { sourceAppLogo(context, accountType) } - if (logo != null) { - Image( - bitmap = logo, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(40.dp) - .clip(CircleShape), - ) - } else { - LeadingAvatar(Icons.Default.Cloud) - } -} - -/** 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) { - val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull() - if (bitmap != null) return bitmap.asImageBitmap() - } - return null -} - -/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */ -@Composable -private fun LeadingAvatar(icon: ImageVector) { - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceContainerHighest), - contentAlignment = Alignment.Center, - ) { - Icon( - icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(22.dp), - ) - } -} @Composable private fun SectionHeader(text: String) { @@ -965,9 +908,3 @@ private fun sourceAppIntent(context: Context, accountType: String): Intent { return Intent(Settings.ACTION_SYNC_SETTINGS) } -/** Preferred app for account types whose authenticator isn't the app to open. */ -private fun curatedSourcePackage(accountType: String): String? = when { - accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar" - else -> null -} - diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt new file mode 100644 index 0000000..dc0103d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarPickerGroups.kt @@ -0,0 +1,180 @@ +package de.jeanlucmakiola.calendula.ui.common + +import android.accounts.AccountManager +import android.content.Context +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.CalendarSource + +/** + * The app's single "which calendar" selection list, shared by the event editor + * and the .ics import screen. Renders the same grouped-card system as the + * calendar-manager screen: a category header per source — the device chip for + * the app's own calendars, the owning app's launcher icon for each synced + * account — with the calendars beneath it as a connected card, a colour chip on + * each and a check on the selected one. Emits into the caller's [ColumnScope] + * (a scrolling column), so the caller owns the surrounding chrome. + */ +@Composable +fun ColumnScope.CalendarPickerGroups( + calendars: List, + selectedId: Long?, + onSelect: (Long) -> Unit, +) { + val local = remember(calendars) { calendars.filter { it.isLocal } } + val syncedGroups = remember(calendars) { + calendars.filterNot { it.isLocal } + .groupBy { it.accountName.ifBlank { it.accountType } } + .toList() + } + + if (local.isNotEmpty()) { + CalendarPickerGroup( + title = stringResource(R.string.calendars_local_header), + leading = { LeadingAvatar(Icons.Default.PhoneAndroid) }, + calendars = local, + selectedId = selectedId, + onSelect = onSelect, + ) + } + syncedGroups.forEachIndexed { index, (account, cals) -> + if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp)) + CalendarPickerGroup( + title = account, + leading = { SourceLogo(cals.first().accountType) }, + calendars = cals, + selectedId = selectedId, + onSelect = onSelect, + ) + } +} + +/** One account's category header (avatar + name) atop its selectable calendars. */ +@Composable +private fun CalendarPickerGroup( + title: String, + leading: @Composable () -> Unit, + calendars: List, + selectedId: Long?, + onSelect: (Long) -> Unit, +) { + GroupedRow( + title = title, + position = Position.Top, + leading = leading, + ) + calendars.forEachIndexed { index, calendar -> + val isSelected = calendar.id == selectedId + GroupedRow( + title = calendar.displayName, + position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle, + selected = isSelected, + leading = { CalendarColorChip(calendar.color) }, + trailing = if (isSelected) { + { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } else { + null + }, + onClick = { onSelect(calendar.id) }, + ) + } +} + +/** + * The source app's launcher icon (Google Calendar, DAVx5, Nextcloud, …) as a + * round 40dp chip, so each synced account is recognisable at a glance. We load + * whatever app owns the account from [android.content.pm.PackageManager] rather + * than bundling brand logos — always accurate, nothing to license. Falls back to + * a neutral cloud chip when no installed app resolves for the account. + */ +@Composable +fun SourceLogo(accountType: String) { + val context = LocalContext.current + val logo = remember(accountType) { sourceAppLogo(context, accountType) } + if (logo != null) { + Image( + bitmap = logo, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(40.dp) + .clip(CircleShape), + ) + } else { + LeadingAvatar(Icons.Default.Cloud) + } +} + +/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */ +@Composable +fun LeadingAvatar(icon: ImageVector) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + contentAlignment = Alignment.Center, + ) { + Icon( + icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } +} + +/** 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) { + val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull() + if (bitmap != null) return bitmap.asImageBitmap() + } + return null +} + +/** 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" + else -> null +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index ac1c6f9..aca10af 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -122,6 +122,7 @@ import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter import de.jeanlucmakiola.calendula.ui.common.predictiveBack import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.calendula.ui.common.DialogAmountField @@ -1975,50 +1976,14 @@ private fun CalendarPicker( onSelect: (Long) -> Unit, onDismiss: () -> Unit, ) { - // Group by owning account (name, else type, else the calendar's own name), - // preserving the provider's order within and across groups — the same - // grouping [groupByAccount] applies for the drawer filter. - val groups = remember(calendars) { - calendars - .groupBy { - it.accountName.takeIf(String::isNotBlank) - ?: it.accountType.takeIf(String::isNotBlank) - ?: it.displayName - } - .toList() - } FullScreenPicker( title = stringResource(R.string.event_detail_calendar), onDismiss = onDismiss, ) { - groups.forEach { (account, cals) -> - Text( - text = account, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp), - ) - cals.forEachIndexed { index, calendar -> - val isSelected = calendar.id == selectedId - GroupedRow( - title = calendar.displayName, - position = positionOf(index, cals.size), - selected = isSelected, - leading = { CalendarColorChip(calendar.color) }, - trailing = if (isSelected) { - { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - } - } else { - null - }, - onClick = { onSelect(calendar.id) }, - ) - } - } + CalendarPickerGroups( + calendars = calendars, + selectedId = selectedId, + onSelect = onSelect, + ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt index 8a9b4d9..cf72b82 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator @@ -39,9 +38,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning -import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip -import de.jeanlucmakiola.calendula.ui.common.GroupedRow -import de.jeanlucmakiola.calendula.ui.common.positionOf +import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups import de.jeanlucmakiola.calendula.ui.common.predictiveBack /** @@ -109,18 +106,6 @@ private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) { } var selected by rememberSaveable { mutableStateOf(state.calendars.first().id) } - // Group by owning account, the same shape the event editor's calendar picker - // uses — coloured chip per calendar, a check on the selected one. - val groups = remember(state.calendars) { - state.calendars - .groupBy { - it.accountName.takeIf(String::isNotBlank) - ?: it.accountType.takeIf(String::isNotBlank) - ?: it.displayName - } - .toList() - } - Column( Modifier.fillMaxSize().verticalScroll(rememberScrollState()) .padding(vertical = 8.dp), @@ -128,43 +113,13 @@ private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) { Text( pluralStringResource(R.plurals.import_event_count, state.events.size, state.events.size), style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 12.dp), ) - Text( - stringResource(R.string.import_target_header), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 4.dp), + CalendarPickerGroups( + calendars = state.calendars, + selectedId = selected, + onSelect = { selected = it }, ) - groups.forEach { (account, cals) -> - Text( - text = account, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp), - ) - cals.forEachIndexed { index, calendar -> - val isSelected = calendar.id == selected - GroupedRow( - title = calendar.displayName, - position = positionOf(index, cals.size), - selected = isSelected, - leading = { CalendarColorChip(calendar.color) }, - trailing = if (isSelected) { - { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - } - } else { - null - }, - onClick = { selected = calendar.id }, - ) - } - } if (state.warnings.isNotEmpty()) { Column( Modifier.padding(horizontal = 24.dp, vertical = 8.dp), diff --git a/floret-kit b/floret-kit new file mode 160000 index 0000000..d9e4e87 --- /dev/null +++ b/floret-kit @@ -0,0 +1 @@ +Subproject commit d9e4e877cc05450bcf121e2cb4140d0efda96f8e