Compare commits

..

12 Commits

Author SHA1 Message Date
e967007bdc fix: default import target to the first local calendar
The pre-selected target was calendars.first() (raw provider order), which
could land on a synced calendar mid-list while the picker shows local
calendars first. Default to the first local calendar so the checkmark lines
up with the top row; fall back to the first calendar when none are local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:41:51 +02:00
9f7427e72f feat: pin import action to the top bar, fold count into title
Move the multi-event import's confirm button into the app-bar actions so
it's reachable without scrolling past a long calendar list, and put the
count in the title ('Importing 5 events') instead of a separate 'N events
in this file' line. Hoists the selected target calendar to the screen so
the top-bar action can read it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:39:54 +02:00
79d9e0eaa0 feat: in-app restore always uses the full import flow
Route by entry point, not just event count. Opening a .ics from outside
still sends a single event straight to the prefilled create form (add one
event, e.g. a ticket). The in-app 'Restore from .ics' button passes
forceMany so even a single-event backup goes through the calendar picker +
summary — its intent is 'restore a backup', not 'add this event'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:29:04 +02:00
a98dd654a6 polish: explain skipped duplicates on the import-complete screen
The import de-dups by UID against the target calendar (idempotent restore),
so re-importing events already present shows a low 'Added' count. Add a note
under the title when any were skipped so the outcome doesn't read as broken.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:07:56 +02:00
a138e179dd feat: per-calendar export selector
Tapping Export with more than one exportable calendar now opens a picker
to choose which local calendars to include (all selected by default); a
single calendar exports straight through as before. Threads an optional
calendarIds filter through exportEvents/exportableEvents (null = all
eligible), so the auto-backup path is unaffected. The backup section is
now gated on there being at least one exportable (non-managed) calendar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:46:24 +02:00
94e3887345 fix: exclude managed special-dates calendars from import targets
Symmetric with the export change: the contact-derived, editor-locked
special-dates mirror calendars aren't a valid import destination, so drop
them from the target-calendar picker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:39:54 +02:00
f440d385fa fix: exclude managed special-dates calendars from .ics export
The contact special-dates mirror calendars (birthdays/anniversaries) are
derived from contacts and re-materialise from the contact sync, so backing
them up only duplicates events on restore. Skip managed calendars in
exportableEvents — covers both the manual export and the auto-backup, which
share this path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:38:21 +02:00
8fb4767888 feat: spice up the import-complete screen
Replace the plain centered text list with an M3 Expressive success state:
a tonal check badge that springs in, the headline, and big-number tonal
stat tiles for added / duplicate-skipped counts, with a full-width Done
button. Stat tiles carry the full-sentence plurals as accessibility
labels so TalkBack still reads 'Imported N events'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:32:20 +02:00
993d74502f chore: stop tracking local floret-kit scratch dir (added by mistake)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:25:20 +02:00
4a11c951ae 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 <noreply@anthropic.com>
2026-07-06 18:23:39 +02:00
f25f308326 fix: import target picker uses the standard grouped-list format
The 'Add to calendar' picker rendered bare OptionCards with no calendar
colour and no account grouping. Reuse the same account-grouped GroupedRow
layout as the event editor's calendar picker — coloured chip per calendar,
account sub-headers, a check on the selected row — so it matches the rest
of the app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:11:45 +02:00
bff683a403 feat: restore events from .ics file in backup section (#32)
Add a 'Restore from .ics file' row to the Calendars backup section, next
to Export. It opens a SAF document picker and routes the picked Uri into
the existing import flow (parse, dedup by UID, target-calendar picker,
summary) via CalendarHost's importUri — the same path an externally
opened .ics already takes, so no new import machinery is needed.

Closes #32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 17:55:35 +02:00
12 changed files with 547 additions and 176 deletions

View File

@@ -71,9 +71,11 @@ 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]).
* 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.
*/
fun exportableEvents(): List<IcsEvent>
fun exportableEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
/**
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
@@ -611,12 +613,19 @@ class AndroidCalendarDataSource @Inject constructor(
?: emptyList()
}
override fun exportableEvents(): List<IcsEvent> {
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
// Only the local calendars the app owns and can write — synced calendars
// already have a backup (their server). Map id → display name for the
// 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
// X-CALENDULA-CALENDAR tag a restore uses to fan back out.
val names = calendars()
.filter { it.isLocal && it.canModifyContents }
.filter {
it.isLocal && it.canModifyContents && !it.isManaged &&
(calendarIds == null || it.id in calendarIds)
}
.associate { it.id to it.displayName }
if (names.isEmpty()) return emptyList()

View File

@@ -41,8 +41,9 @@ 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(): List<IcsEvent>
suspend fun exportEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
/**
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose

View File

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

View File

@@ -172,9 +172,15 @@ 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()
}
}
@@ -412,7 +418,10 @@ fun CalendarHost(
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
CalendarsScreen(onBack = { showCalendars = false })
CalendarsScreen(
onBack = { showCalendars = false },
onImport = { importUri = it; importForceMany = true },
)
}
// Import flow for an opened/received .ics file. A single event routes
@@ -420,6 +429,7 @@ fun CalendarHost(
importUri?.let { uri ->
ImportScreen(
uri = uri,
forceMany = importForceMany,
onClose = { importUri = null },
onOpenSingle = { form ->
importUri = null

View File

@@ -37,6 +37,7 @@ 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
@@ -45,6 +46,7 @@ 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
@@ -96,6 +98,11 @@ 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
@@ -112,6 +119,16 @@ 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),
@@ -122,6 +139,7 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE
@Composable
fun CalendarsScreen(
onBack: () -> Unit,
onImport: (android.net.Uri) -> Unit,
viewModel: CalendarsViewModel = hiltViewModel(),
) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
@@ -168,6 +186,7 @@ fun CalendarsScreen(
onConsumeError = viewModel::consumeError,
backupResult = backupResult,
onExportBackup = viewModel::exportBackup,
onImport = onImport,
onConsumeBackupResult = viewModel::consumeBackupResult,
autoBackup = autoBackup,
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
@@ -190,7 +209,8 @@ private fun CalendarsList(
error: Boolean,
onConsumeError: () -> Unit,
backupResult: BackupResult?,
onExportBackup: (android.net.Uri) -> Unit,
onExportBackup: (android.net.Uri, Set<Long>?) -> Unit,
onImport: (android.net.Uri) -> Unit,
onConsumeBackupResult: () -> Unit,
autoBackup: AutoBackupUiState,
onSetAutoBackupEnabled: (Boolean) -> Unit,
@@ -218,10 +238,19 @@ private fun CalendarsList(
}
// SAF "create document" target for the backup file. The picked Uri is handed
// to the VM to stream the .ics into.
// to the VM to stream the .ics into. This launcher exports everything
// eligible (null); the per-calendar selector owns its own launcher.
val createBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/calendar"),
) { uri -> uri?.let(onExportBackup) }
) { 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) }
// SAF folder picker for the automatic-backup destination; the VM persists the
// write grant so background runs can keep writing to it.
@@ -301,8 +330,10 @@ private fun CalendarsList(
}
// Backup — local calendars have no sync, so a .ics export is their only
// safety net. Offered only when there is something to back up.
if (local.isNotEmpty()) {
// 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()) {
Spacer(Modifier.height(16.dp))
SectionHeader(stringResource(R.string.calendars_backup_header))
HintText(stringResource(R.string.calendars_backup_hint))
@@ -313,7 +344,22 @@ private fun CalendarsList(
position = Position.Top,
leading = { LeadingAvatar(Icons.Default.FileDownload) },
onClick = {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
// 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) }
},
)
GroupedRow(
@@ -408,6 +454,76 @@ 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)
@@ -741,66 +857,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) {
@@ -935,9 +991,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
}

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) {
fun exportBackup(uri: Uri, calendarIds: Set<Long>? = null) {
viewModelScope.launch {
_backupResult.value = try {
val count = withContext(io) {
val events = repository.exportEvents()
val events = repository.exportEvents(calendarIds)
icsExporter.writeDocument(
uri = uri,
content = IcsWriter().writeCalendar(events, Clock.System.now()),

View File

@@ -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<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,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,
)
}
}

View File

@@ -1,15 +1,27 @@
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
@@ -18,6 +30,7 @@ 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
@@ -30,6 +43,12 @@ 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
@@ -38,14 +57,16 @@ 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.
* 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".
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -53,9 +74,10 @@ fun ImportScreen(
uri: Uri,
onClose: () -> Unit,
onOpenSingle: (EventForm) -> Unit,
forceMany: Boolean = false,
viewModel: ImportViewModel = hiltViewModel(),
) {
LaunchedEffect(uri) { viewModel.load(uri) }
LaunchedEffect(uri) { viewModel.load(uri, forceMany) }
val state by viewModel.state.collectAsStateWithLifecycle()
// A single event isn't shown here — it opens the create form for review.
@@ -63,18 +85,55 @@ 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(stringResource(R.string.import_title)) },
title = {
Text(
if (many != null) {
pluralStringResource(
R.plurals.import_title_count,
many.events.size,
many.events.size,
)
} else {
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,
),
@@ -90,7 +149,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, onImport = viewModel::import)
is ImportUiState.Many -> ManyContent(s, selected, onSelect = { selected = it })
is ImportUiState.Done -> DoneContent(s, onClose)
}
}
@@ -98,84 +157,160 @@ fun ImportScreen(
}
@Composable
private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) {
private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (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(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
.padding(top = 8.dp, bottom = 24.dp),
) {
Text(
pluralStringResource(R.plurals.import_event_count, state.events.size, state.events.size),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(vertical = 8.dp),
CalendarPickerGroups(
calendars = state.calendars,
selectedId = selected,
onSelect = onSelect,
)
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))
if (state.warnings.isNotEmpty()) {
Column(
Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
state.warnings.forEach { WarningText(it) }
}
}
}
}
@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,
modifier = Modifier.padding(top = 24.dp),
)
Text(
pluralStringResource(
R.plurals.import_done_imported,
state.summary.imported,
state.summary.imported,
),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
if (state.summary.skippedDuplicate > 0) {
Spacer(Modifier.height(8.dp))
Text(
pluralStringResource(
R.plurals.import_done_skipped,
state.summary.skippedDuplicate,
state.summary.skippedDuplicate,
),
stringResource(R.string.import_done_dedup_note),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
Button(onClick = onClose, modifier = Modifier.padding(top = 12.dp)) {
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(),
) {
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,8 +66,13 @@ class ImportViewModel @Inject constructor(
val state: StateFlow<ImportUiState> = _state.asStateFlow()
private var started = false
/** Read + parse [uri] once; subsequent calls (recomposition) are ignored. */
fun load(uri: Uri) {
/**
* 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) {
if (started) return
started = true
viewModelScope.launch {
@@ -77,19 +82,21 @@ class ImportViewModel @Inject constructor(
_state.value = when {
parsed == null -> ImportUiState.Failed
parsed.events.isEmpty() -> ImportUiState.Empty
parsed.events.size == 1 -> ImportUiState.Single(
parsed.events.size == 1 && !forceMany -> 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.id !in disabled },
.filter { it.canModifyContents && !it.isManaged && it.id !in disabled },
)
}
}

View File

@@ -428,6 +428,11 @@
<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>
@@ -460,11 +465,19 @@
<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(): List<IcsEvent> = exportableEventsResult
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> = exportableEventsResult
override fun existingUids(calendarId: Long): Set<String> = existingUidsResult