Compare commits

..

1 Commits

Author SHA1 Message Date
31f51554f9 feat: open Day view when tapping a date header in Week/Agenda
Tapping a date header in the Week (day-of-week column) and Agenda
(sticky section) views now drills into that date in Day view, mirroring
the Agenda widget's header behaviour. Both reuse the existing onOpenDay
callback (pendingDayIso + drillToDay) that Month already used, so the
back stack lands on Day with the tapped view as its parent.

Month already navigated on any cell tap (the transparent tap layer sits
above the day number), so no change was needed there — all four views
now behave consistently.

Closes #37

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:51:49 +02:00
14 changed files with 207 additions and 553 deletions

View File

@@ -71,11 +71,9 @@ interface CalendarDataSource {
/**
* Every master/one-off event of the writable local calendars, mapped for a
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
* rows are excluded (see [EventExportProjection]). When [calendarIds] is
* given, only those calendars are exported (still intersected with the
* eligible set); `null` exports every eligible calendar.
* rows are excluded (see [EventExportProjection]).
*/
fun exportableEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
fun exportableEvents(): List<IcsEvent>
/**
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
@@ -613,19 +611,12 @@ class AndroidCalendarDataSource @Inject constructor(
?: emptyList()
}
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
override fun exportableEvents(): List<IcsEvent> {
// Only the local calendars the app owns and can write — synced calendars
// already have a backup (their server). Exclude the managed special-dates
// mirror calendars: their events are derived from contacts, not authored
// here, and re-materialise from the contact sync — backing them up would
// just duplicate them on restore. A non-null [calendarIds] narrows the
// export to the user's chosen subset. Map id → display name for the
// already have a backup (their server). Map id → display name for the
// X-CALENDULA-CALENDAR tag a restore uses to fan back out.
val names = calendars()
.filter {
it.isLocal && it.canModifyContents && !it.isManaged &&
(calendarIds == null || it.id in calendarIds)
}
.filter { it.isLocal && it.canModifyContents }
.associate { it.id to it.displayName }
if (names.isEmpty()) return emptyList()

View File

@@ -41,9 +41,8 @@ interface CalendarRepository {
/**
* Every event of the writable local calendars, ready to serialise into a
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
* [calendarIds] narrows the export to a chosen subset; `null` exports all.
*/
suspend fun exportEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
suspend fun exportEvents(): List<IcsEvent>
/**
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose

View File

@@ -117,8 +117,7 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun deleteCalendar(id: Long) =
withContext(io) { dataSource.deleteCalendar(id) }
override suspend fun exportEvents(calendarIds: Set<Long>?) =
withContext(io) { dataSource.exportableEvents(calendarIds) }
override suspend fun exportEvents() = withContext(io) { dataSource.exportableEvents() }
override suspend fun importEvents(
targetCalendarId: Long,

View File

@@ -172,15 +172,9 @@ fun CalendarHost(
// picker (many). A plain conditional overlay (no slide) — it's transient.
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
var importForm by remember { mutableStateOf<EventForm?>(null) }
// A restore (in-app "Restore from .ics" button) always runs the full import
// flow — picker + summary — even for a single-event file, because the intent
// is "restore a backup", not "add this one event". An externally opened .ics
// keeps routing a single event straight into the prefilled create form.
var importForceMany by remember { mutableStateOf(false) }
LaunchedEffect(requestedImportUri) {
if (requestedImportUri != null) {
importUri = requestedImportUri
importForceMany = false
onImportConsumed()
}
}
@@ -290,6 +284,7 @@ fun CalendarHost(
CalendarView.Week -> WeekScreen(
selectedView = currentView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
@@ -321,6 +316,7 @@ fun CalendarHost(
CalendarView.Agenda -> AgendaScreen(
selectedView = currentView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
@@ -418,10 +414,7 @@ fun CalendarHost(
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
CalendarsScreen(
onBack = { showCalendars = false },
onImport = { importUri = it; importForceMany = true },
)
CalendarsScreen(onBack = { showCalendars = false })
}
// Import flow for an opened/received .ics file. A single event routes
@@ -429,7 +422,6 @@ fun CalendarHost(
importUri?.let { uri ->
ImportScreen(
uri = uri,
forceMany = importForceMany,
onClose = { importUri = null },
onOpenSingle = { form ->
importUri = null

View File

@@ -93,6 +93,7 @@ private val zone = TimeZone.currentSystemDefault()
fun AgendaScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
@@ -191,6 +192,7 @@ fun AgendaScreen(
pastDisplay = pastDisplay,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
@@ -289,6 +291,7 @@ private fun AgendaContent(
pastDisplay: PastEventDisplay,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
when (state) {
@@ -318,6 +321,7 @@ private fun AgendaContent(
dimPast = pastDisplay == PastEventDisplay.DIM,
now = now,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
modifier = modifier,
)
}
@@ -333,6 +337,7 @@ private fun AgendaList(
dimPast: Boolean,
now: Instant,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
@@ -342,7 +347,7 @@ private fun AgendaList(
) {
days.forEach { day ->
stickyHeader(key = "header-${day.date}") {
AgendaDayHeader(date = day.date, today = today)
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
}
itemsIndexed(
items = day.events,
@@ -362,10 +367,16 @@ private fun AgendaList(
}
@Composable
private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
private fun AgendaDayHeader(
date: LocalDate,
today: LocalDate,
onOpenDay: (LocalDate) -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenDay(date) },
) {
Text(
text = agendaDayLabel(date, today),

View File

@@ -37,7 +37,6 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.FileUpload
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PhoneAndroid
@@ -46,7 +45,6 @@ import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -98,11 +96,6 @@ 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.FullScreenPicker
import de.jeanlucmakiola.calendula.ui.common.positionOf
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
@@ -119,16 +112,6 @@ import java.time.LocalDate
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
// SAF mime filter for the restore picker. `.ics` files reach us under several
// mimes depending on the source app (our own export uses text/calendar; others
// hand them out as octet-stream or text/plain), so accept the common set rather
// than hide valid backups behind an over-tight filter.
private val RESTORE_MIME_TYPES = arrayOf(
"text/calendar",
"application/octet-stream",
"text/plain",
)
/**
* Calendar manager (reached from Settings). Lists the app's own device-only
* calendars with create / rename / recolor / delete (via a full-screen editor),
@@ -139,7 +122,6 @@ private val RESTORE_MIME_TYPES = arrayOf(
@Composable
fun CalendarsScreen(
onBack: () -> Unit,
onImport: (android.net.Uri) -> Unit,
viewModel: CalendarsViewModel = hiltViewModel(),
) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
@@ -186,7 +168,6 @@ fun CalendarsScreen(
onConsumeError = viewModel::consumeError,
backupResult = backupResult,
onExportBackup = viewModel::exportBackup,
onImport = onImport,
onConsumeBackupResult = viewModel::consumeBackupResult,
autoBackup = autoBackup,
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
@@ -209,8 +190,7 @@ private fun CalendarsList(
error: Boolean,
onConsumeError: () -> Unit,
backupResult: BackupResult?,
onExportBackup: (android.net.Uri, Set<Long>?) -> Unit,
onImport: (android.net.Uri) -> Unit,
onExportBackup: (android.net.Uri) -> Unit,
onConsumeBackupResult: () -> Unit,
autoBackup: AutoBackupUiState,
onSetAutoBackupEnabled: (Boolean) -> Unit,
@@ -238,19 +218,10 @@ private fun CalendarsList(
}
// SAF "create document" target for the backup file. The picked Uri is handed
// to the VM to stream the .ics into. This launcher exports everything
// eligible (null); the per-calendar selector owns its own launcher.
// to the VM to stream the .ics into.
val createBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/calendar"),
) { uri -> uri?.let { onExportBackup(it, null) } }
var showExportPicker by remember { mutableStateOf(false) }
// SAF "open document" picker for restoring events from a .ics file. The
// picked Uri is handed up to the host, which runs it through the same import
// flow as an externally opened .ics (parse, dedup by UID, target picker).
val openBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri -> uri?.let(onImport) }
) { uri -> uri?.let(onExportBackup) }
// SAF folder picker for the automatic-backup destination; the VM persists the
// write grant so background runs can keep writing to it.
@@ -330,10 +301,8 @@ private fun CalendarsList(
}
// Backup — local calendars have no sync, so a .ics export is their only
// safety net. Offered only when there is something exportable: the user's
// own local calendars (managed special-dates mirrors don't count).
val exportable = local.filter { it.canModifyContents && !it.isManaged }
if (exportable.isNotEmpty()) {
// safety net. Offered only when there is something to back up.
if (local.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
SectionHeader(stringResource(R.string.calendars_backup_header))
HintText(stringResource(R.string.calendars_backup_hint))
@@ -344,22 +313,7 @@ private fun CalendarsList(
position = Position.Top,
leading = { LeadingAvatar(Icons.Default.FileDownload) },
onClick = {
// With more than one exportable calendar, let the user choose
// which to include; a single one exports straight away.
if (exportable.size == 1) {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
} else {
showExportPicker = true
}
},
)
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
summary = stringResource(R.string.calendars_restore_hint),
position = Position.Middle,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = {
runCatching { openBackup.launch(RESTORE_MIME_TYPES) }
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
},
)
GroupedRow(
@@ -454,76 +408,6 @@ private fun CalendarsList(
onDismiss = { showInterval = false },
)
}
if (showExportPicker) {
ExportCalendarPicker(
calendars = local.filter { it.canModifyContents && !it.isManaged },
onExport = onExportBackup,
onDismiss = { showExportPicker = false },
)
}
}
/**
* Choose which local calendars to include in a one-time `.ics` export. Defaults
* to all selected; the Export action opens the SAF save dialog and hands back
* the picked file with the chosen calendar ids.
*/
@Composable
private fun ExportCalendarPicker(
calendars: List<CalendarSource>,
onExport: (android.net.Uri, Set<Long>?) -> Unit,
onDismiss: () -> Unit,
) {
var selected by remember(calendars) {
mutableStateOf(calendars.map { it.id }.toSet())
}
val createBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/calendar"),
) { uri ->
if (uri != null) {
onExport(uri, selected)
onDismiss()
}
}
FullScreenPicker(
title = stringResource(R.string.calendars_export_title),
onDismiss = onDismiss,
) {
HintText(stringResource(R.string.calendars_export_hint))
calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id in selected
GroupedRow(
title = calendar.displayName,
summary = calendar.description,
position = positionOf(index, calendars.size),
leading = { CalendarColorChip(calendar.color) },
trailing = {
Checkbox(
checked = isSelected,
onCheckedChange = { checked ->
selected = if (checked) selected + calendar.id else selected - calendar.id
},
)
},
onClick = {
selected = if (isSelected) selected - calendar.id else selected + calendar.id
},
)
}
Button(
onClick = {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
},
enabled = selected.isNotEmpty(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
) {
Text(stringResource(R.string.calendars_export_action))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -857,6 +741,66 @@ 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) {
@@ -991,3 +935,9 @@ 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
}

View File

@@ -107,11 +107,11 @@ class CalendarsViewModel @Inject constructor(
* document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
* in [backupResult] for a one-shot message.
*/
fun exportBackup(uri: Uri, calendarIds: Set<Long>? = null) {
fun exportBackup(uri: Uri) {
viewModelScope.launch {
_backupResult.value = try {
val count = withContext(io) {
val events = repository.exportEvents(calendarIds)
val events = repository.exportEvents()
icsExporter.writeDocument(
uri = uri,
content = IcsWriter().writeCalendar(events, Clock.System.now()),

View File

@@ -1,180 +0,0 @@
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<CalendarSource>,
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<CalendarSource>,
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
}

View File

@@ -122,7 +122,6 @@ 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
@@ -1976,14 +1975,50 @@ 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,
) {
CalendarPickerGroups(
calendars = calendars,
selectedId = selectedId,
onSelect = onSelect,
)
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) },
)
}
}
}
}

View File

@@ -1,27 +1,15 @@
package de.jeanlucmakiola.calendula.ui.imports
import android.net.Uri
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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
@@ -30,7 +18,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
@@ -43,12 +30,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -57,16 +38,14 @@ 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.CalendarPickerGroups
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.OptionCard
/**
* Handles an opened/received `.ics` file. A single event is handed straight to
* the prefilled create form via [onOpenSingle]; several events show a target-
* calendar picker and import in bulk (dedup by UID), then a result summary.
* Empty/failed files show a short message and close. [forceMany] keeps a
* single-event file on the bulk path — used by the in-app restore, whose intent
* is "restore a backup" rather than "add this one event".
* Empty/failed files show a short message and close.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -74,10 +53,9 @@ fun ImportScreen(
uri: Uri,
onClose: () -> Unit,
onOpenSingle: (EventForm) -> Unit,
forceMany: Boolean = false,
viewModel: ImportViewModel = hiltViewModel(),
) {
LaunchedEffect(uri) { viewModel.load(uri, forceMany) }
LaunchedEffect(uri) { viewModel.load(uri) }
val state by viewModel.state.collectAsStateWithLifecycle()
// A single event isn't shown here — it opens the create form for review.
@@ -85,55 +63,18 @@ fun ImportScreen(
(state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
}
// Hoisted target calendar so the always-visible top-bar Import action can
// read it without the user scrolling to a bottom button. Defaults to the
// first *local* calendar — the first row the picker shows ("Your calendars"
// group leads) — so the pre-selection lines up with the top of the list;
// falls back to the first calendar if there are no local ones. Re-defaults
// when the "many" list first arrives (keyed on it), then holds the pick.
val many = state as? ImportUiState.Many
val defaultTarget = many?.calendars?.let { cals ->
(cals.firstOrNull { it.isLocal } ?: cals.firstOrNull())?.id
}
var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) }
Scaffold(
modifier = Modifier
.predictiveBack(onBack = onClose)
.fillMaxSize(),
topBar = {
TopAppBar(
title = {
Text(
if (many != null) {
pluralStringResource(
R.plurals.import_title_count,
many.events.size,
many.events.size,
)
} else {
stringResource(R.string.import_title)
},
)
},
title = { Text(stringResource(R.string.import_title)) },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
}
},
actions = {
// Only meaningful in the multi-event picker with a writable
// target; every other state has nothing to confirm here.
if (many != null && many.calendars.isNotEmpty()) {
Button(
onClick = { selected?.let(viewModel::import) },
enabled = selected != null,
modifier = Modifier.padding(end = 12.dp),
) {
Text(stringResource(R.string.import_button))
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
@@ -149,7 +90,7 @@ fun ImportScreen(
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
is ImportUiState.Many -> ManyContent(s, selected, onSelect = { selected = it })
is ImportUiState.Many -> ManyContent(s, onImport = viewModel::import)
is ImportUiState.Done -> DoneContent(s, onClose)
}
}
@@ -157,160 +98,84 @@ fun ImportScreen(
}
@Composable
private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (Long) -> Unit) {
private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) {
// No writable calendar to import into — tell the user honestly.
if (state.calendars.isEmpty()) {
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
return
}
var selected by rememberSaveable { mutableStateOf(state.calendars.first().id) }
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
.padding(top = 8.dp, bottom = 24.dp),
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
CalendarPickerGroups(
calendars = state.calendars,
selectedId = selected,
onSelect = onSelect,
Text(
pluralStringResource(R.plurals.import_event_count, state.events.size, state.events.size),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(vertical = 8.dp),
)
if (state.warnings.isNotEmpty()) {
Column(
Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
state.warnings.forEach { WarningText(it) }
}
Text(
stringResource(R.string.import_target_header),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
state.calendars.forEach { calendar ->
OptionCard(
label = calendar.displayName,
onClick = { selected = calendar.id },
selected = calendar.id == selected,
icon = null,
)
}
state.warnings.forEach { WarningText(it) }
Button(
onClick = { onImport(selected) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) {
Text(pluralStringResource(R.plurals.import_action, state.events.size, state.events.size))
}
}
}
@Composable
private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
// A little expressive pop on the success badge — springs in on first show.
val badgeScale = remember { Animatable(0.7f) }
LaunchedEffect(Unit) {
badgeScale.animateTo(
targetValue = 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow,
),
)
}
Column(
Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.weight(1f))
Box(
Modifier
.size(112.dp)
.graphicsLayer {
scaleX = badgeScale.value
scaleY = badgeScale.value
}
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp),
)
}
Spacer(Modifier.height(24.dp))
Text(
stringResource(R.string.import_done_title),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(top = 24.dp),
)
Text(
pluralStringResource(
R.plurals.import_done_imported,
state.summary.imported,
state.summary.imported,
),
style = MaterialTheme.typography.bodyLarge,
)
if (state.summary.skippedDuplicate > 0) {
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.import_done_dedup_note),
pluralStringResource(
R.plurals.import_done_skipped,
state.summary.skippedDuplicate,
state.summary.skippedDuplicate,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(24.dp))
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
ImportStatCard(
count = state.summary.imported,
label = stringResource(R.string.import_done_added_label),
contentDescription = pluralStringResource(
R.plurals.import_done_imported,
state.summary.imported,
state.summary.imported,
),
container = MaterialTheme.colorScheme.secondaryContainer,
onContainer = MaterialTheme.colorScheme.onSecondaryContainer,
)
if (state.summary.skippedDuplicate > 0) {
ImportStatCard(
count = state.summary.skippedDuplicate,
label = stringResource(R.string.import_done_skipped_label),
contentDescription = pluralStringResource(
R.plurals.import_done_skipped,
state.summary.skippedDuplicate,
state.summary.skippedDuplicate,
),
container = MaterialTheme.colorScheme.surfaceContainerHighest,
onContainer = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.weight(1f))
Button(
onClick = onClose,
modifier = Modifier.fillMaxWidth(),
) {
Button(onClick = onClose, modifier = Modifier.padding(top = 12.dp)) {
Text(stringResource(R.string.import_close))
}
}
}
/** A big-number tonal tile summarising one import outcome (added / skipped). */
@Composable
private fun RowScope.ImportStatCard(
count: Int,
label: String,
contentDescription: String,
container: Color,
onContainer: Color,
) {
Surface(
modifier = Modifier
.weight(1f)
.clearAndSetSemantics { this.contentDescription = contentDescription },
shape = RoundedCornerShape(24.dp),
color = container,
) {
Column(
Modifier.padding(vertical = 20.dp, horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
count.toString(),
style = MaterialTheme.typography.displaySmall,
color = onContainer,
)
Text(
label,
style = MaterialTheme.typography.labelLarge,
color = onContainer.copy(alpha = 0.85f),
)
}
}
}
@Composable
private fun WarningText(warning: IcsParseWarning) {
val text = when (warning) {

View File

@@ -66,13 +66,8 @@ class ImportViewModel @Inject constructor(
val state: StateFlow<ImportUiState> = _state.asStateFlow()
private var started = false
/**
* Read + parse [uri] once; subsequent calls (recomposition) are ignored.
* When [forceMany] is set (an in-app restore), a single-event file still goes
* through the bulk picker + summary rather than the prefilled create form —
* a restore is "bring back a backup", not "add this one event".
*/
fun load(uri: Uri, forceMany: Boolean = false) {
/** Read + parse [uri] once; subsequent calls (recomposition) are ignored. */
fun load(uri: Uri) {
if (started) return
started = true
viewModelScope.launch {
@@ -82,21 +77,19 @@ class ImportViewModel @Inject constructor(
_state.value = when {
parsed == null -> ImportUiState.Failed
parsed.events.isEmpty() -> ImportUiState.Empty
parsed.events.size == 1 && !forceMany -> ImportUiState.Single(
parsed.events.size == 1 -> ImportUiState.Single(
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
warnings = parsed.warnings,
)
else -> {
// A disabled calendar is removed from the app, so it can't be
// an import target — exclude it alongside the read-only ones.
// Managed special-dates calendars are contact-derived and
// editor-locked, so they're not a valid destination either.
val disabled = prefs.disabledCalendarIds.first()
ImportUiState.Many(
events = parsed.events,
warnings = parsed.warnings,
calendars = repository.calendars().first()
.filter { it.canModifyContents && !it.isManaged && it.id !in disabled },
.filter { it.canModifyContents && it.id !in disabled },
)
}
}

View File

@@ -128,6 +128,7 @@ private fun WeekUiState.Success.allDayStripHeight(): Dp {
fun WeekScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
@@ -249,6 +250,7 @@ fun WeekScreen(
onSwipePrev = goPrev,
onRetry = jumpToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
modifier = Modifier
.padding(innerPadding)
@@ -268,6 +270,7 @@ private fun WeekContent(
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -342,6 +345,7 @@ private fun WeekContent(
scrollState = scrollState,
allDayHeight = allDayHeight,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
)
}
@@ -355,6 +359,7 @@ private fun WeekSuccess(
scrollState: ScrollState,
allDayHeight: Dp,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
@@ -363,7 +368,7 @@ private fun WeekSuccess(
.fillMaxWidth()
.background(topSectionColor),
) {
WeekDayHeader(days = state.days, today = state.today)
WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay)
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
}
// Breathing room between the (colour-shifting) top section and the
@@ -427,7 +432,11 @@ private fun WeekTopBar(
}
@Composable
private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) {
private fun WeekDayHeader(
days: List<LocalDate>,
today: LocalDate,
onOpenDay: (LocalDate) -> Unit,
) {
val locale = currentLocale()
val weekStart = days.first()
val weekNumber = remember(weekStart) {
@@ -453,7 +462,10 @@ private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) {
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
val isToday = date == today
Column(
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.clickable { onOpenDay(date) },
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(

View File

@@ -428,11 +428,6 @@
<string name="calendars_backup_header">Backup</string>
<string name="calendars_backup_hint">Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy.</string>
<string name="calendars_backup_action">Export as .ics file</string>
<string name="calendars_export_title">Export calendars</string>
<string name="calendars_export_hint">Choose which calendars to include in the .ics file.</string>
<string name="calendars_export_action">Export</string>
<string name="calendars_restore_action">Restore from .ics file</string>
<string name="calendars_restore_hint">Import events from a backup or another calendar app.</string>
<string name="calendars_auto_backup">Automatic backup</string>
<string name="calendars_auto_backup_hint">Periodically export your local calendars to a folder as an .ics file.</string>
<string name="calendars_auto_backup_folder">Backup folder</string>
@@ -465,19 +460,11 @@
<string name="import_failed">Couldn\'t read this file.</string>
<string name="import_no_calendar">No writable calendar to import into. Create a local calendar first.</string>
<string name="import_done_title">Import complete</string>
<string name="import_done_dedup_note">Events already in the calendar were skipped.</string>
<string name="import_done_added_label">Added</string>
<string name="import_done_skipped_label">Duplicates</string>
<string name="import_close">Close</string>
<string name="import_warning_recurrence">Some changed occurrences of recurring events were skipped.</string>
<string name="import_warning_no_start">An event without a start time was skipped.</string>
<string name="import_warning_attendees">Guest lists weren\'t imported.</string>
<string name="import_warning_timezone">An unknown time zone fell back to your device\'s.</string>
<string name="import_button">Import</string>
<plurals name="import_title_count">
<item quantity="one">Importing %d event</item>
<item quantity="other">Importing %d events</item>
</plurals>
<plurals name="import_event_count">
<item quantity="one">%d event in this file.</item>
<item quantity="other">%d events in this file.</item>

View File

@@ -59,7 +59,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
eventColorPaletteResult(calendarId)
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> = exportableEventsResult
override fun exportableEvents(): List<IcsEvent> = exportableEventsResult
override fun existingUids(calendarId: Long): Set<String> = existingUidsResult