diff --git a/CHANGELOG.md b/CHANGELOG.md
index fa1423c..98bdf47 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,10 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Back up your local (device-only) calendars: Settings → Calendars → Export as
`.ics` file writes every event of your on-device calendars to a file you
choose. Local calendars aren't synced anywhere, so this is their only backup.
- (Importing `.ics` files back in lands in the next update.)
-
-_Note: new events now carry a unique identifier so a future `.ics` import can
-recognise them and avoid duplicates._
+- Open or share an `.ics` file into Calendula: a single event opens the create
+ form prefilled for review, while a file with many events (e.g. a backup) opens
+ a bulk import — pick a calendar and import them all. Re-importing a backup
+ won't create duplicates (events are matched by their unique identifier), and
+ anything Calendula can't represent (changed recurring occurrences, guest
+ lists) is reported rather than silently dropped.
## [2.6.0] — 2026-06-18
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4bdb178..772da76 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -47,6 +47,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
(null)
+ // An .ics file opened/shared into the app (ACTION_VIEW/SEND). Consumed once
+ // by CalendarHost's import flow.
+ private var requestedImportUri by mutableStateOf(null)
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestedDetailKey = intent.detailKeyOrNull()
requestedNav = intent.navRequestOrNull()
+ requestedImportUri = intent.importUriOrNull()
setContent {
// One activity-scoped SettingsViewModel drives both the theme here
// and the Settings screen, so a theme change applies app-wide at once.
@@ -60,6 +67,8 @@ class MainActivity : AppCompatActivity() {
onDetailKeyConsumed = { requestedDetailKey = null },
widgetNavRequest = requestedNav,
onWidgetNavConsumed = { requestedNav = null },
+ requestedImportUri = requestedImportUri,
+ onImportConsumed = { requestedImportUri = null },
)
}
}
@@ -69,6 +78,21 @@ class MainActivity : AppCompatActivity() {
super.onNewIntent(intent)
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
intent.navRequestOrNull()?.let { requestedNav = it }
+ intent.importUriOrNull()?.let { requestedImportUri = it }
+ }
+
+ /**
+ * The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
+ * or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
+ * app's own `calendula://` deep-links never match.
+ */
+ private fun Intent.importUriOrNull(): Uri? {
+ val uri = when (action) {
+ Intent.ACTION_VIEW -> data
+ Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
+ else -> null
+ } ?: return null
+ return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
}
private fun Intent.navRequestOrNull(): WidgetNavRequest? = when {
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
index 92b6ffd..941f80f 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
@@ -15,6 +15,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
@@ -23,6 +24,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.day.DayScreen
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
+import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
@@ -48,6 +50,8 @@ fun CalendarHost(
onDetailKeyConsumed: () -> Unit = {},
widgetNavRequest: WidgetNavRequest? = null,
onWidgetNavConsumed: () -> Unit = {},
+ requestedImportUri: android.net.Uri? = null,
+ onImportConsumed: () -> Unit = {},
) {
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
val onSelectView: (CalendarView) -> Unit = { view = it }
@@ -121,6 +125,18 @@ fun CalendarHost(
var editKey by rememberSaveable { mutableStateOf(null) }
var heldEditKey by remember { mutableStateOf(null) }
+ // An opened/received .ics file. [ImportScreen] parses it and either opens
+ // the prefilled create form (one event → [importForm]) or its own bulk
+ // picker (many). A plain conditional overlay (no slide) — it's transient.
+ var importUri by remember { mutableStateOf(null) }
+ var importForm by remember { mutableStateOf(null) }
+ LaunchedEffect(requestedImportUri) {
+ if (requestedImportUri != null) {
+ importUri = requestedImportUri
+ onImportConsumed()
+ }
+ }
+
// A home-screen widget launch asks to open a date (→ day view) or start a
// create. Handled once and cleared, mirroring [requestedDetailKey].
LaunchedEffect(widgetNavRequest) {
@@ -254,5 +270,26 @@ fun CalendarHost(
) {
CalendarsScreen(onBack = { showCalendars = false })
}
+
+ // Import flow for an opened/received .ics file. A single event routes
+ // into the create form (prefilled, for review); many open the picker.
+ importUri?.let { uri ->
+ ImportScreen(
+ uri = uri,
+ onClose = { importUri = null },
+ onOpenSingle = { form ->
+ importUri = null
+ importForm = form
+ },
+ )
+ }
+ importForm?.let { form ->
+ EventEditScreen(
+ initialDateIso = null,
+ initialForm = form,
+ onClose = { importForm = null },
+ onSaved = { importForm = null },
+ )
+ }
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt
index 9300645..aae4d82 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt
@@ -27,6 +27,8 @@ fun RootScreen(
onDetailKeyConsumed: () -> Unit = {},
widgetNavRequest: WidgetNavRequest? = null,
onWidgetNavConsumed: () -> Unit = {},
+ requestedImportUri: android.net.Uri? = null,
+ onImportConsumed: () -> Unit = {},
) {
val context = LocalContext.current
var hasPermission by remember {
@@ -62,6 +64,8 @@ fun RootScreen(
onDetailKeyConsumed = onDetailKeyConsumed,
widgetNavRequest = widgetNavRequest,
onWidgetNavConsumed = onWidgetNavConsumed,
+ requestedImportUri = requestedImportUri,
+ onImportConsumed = onImportConsumed,
)
false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish,
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 5ea434f..3f9866e 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
@@ -98,6 +98,7 @@ import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption
+import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.EventFormProblem
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
@@ -156,19 +157,23 @@ fun EventEditScreen(
onSaved: () -> Unit,
editKey: LongArray? = null,
initialStartMinutes: Int? = null,
+ initialForm: EventForm? = null,
viewModel: EventEditViewModel = hiltViewModel(),
) {
- LaunchedEffect(initialDateIso, editKey) {
- if (editKey != null) {
- viewModel.openForEdit(
+ LaunchedEffect(initialDateIso, editKey, initialForm) {
+ when {
+ // Single-event .ics open: the form arrives prefilled for review.
+ initialForm != null -> viewModel.openImported(initialForm)
+ editKey != null -> viewModel.openForEdit(
eventId = editKey[0],
beginMillis = editKey[1],
endMillis = editKey[2],
)
- } else {
- val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
- ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
- viewModel.openNew(date, initialStartMinutes)
+ else -> {
+ val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
+ ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
+ viewModel.openNew(date, initialStartMinutes)
+ }
}
}
val state by viewModel.state.collectAsStateWithLifecycle()
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt
index 8fd8dc6..d30ece4 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt
@@ -210,6 +210,21 @@ class EventEditViewModel @Inject constructor(
applyDefaultReminder()
}
+ /**
+ * Seed a fresh event from a parsed `.ics` file (the single-event "open into
+ * the create form" path). [form] already carries the file's fields; its
+ * [EventForm.calendarId] is null so the calendar still resolves to the
+ * last-used/first-writable one, and reminders are frozen as touched so the
+ * settings default never overwrites what the file specified. No-op when a
+ * form is already open, so the prefill survives configuration changes.
+ */
+ fun openImported(form: EventForm) {
+ if (_form.value != null || _editTarget.value != null) return
+ _remindersTouched.value = true
+ _revealed.value = form.populatedFields()
+ _form.value = form
+ }
+
/**
* Prefill a new event's reminders from the settings default — the all-day
* default for all-day events, otherwise the resolved calendar's per-calendar
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
new file mode 100644
index 0000000..92e152e
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt
@@ -0,0 +1,207 @@
+package de.jeanlucmakiola.calendula.ui.imports
+
+import android.net.Uri
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+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.Close
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+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.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+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.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.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ImportScreen(
+ uri: Uri,
+ onClose: () -> Unit,
+ onOpenSingle: (EventForm) -> Unit,
+ viewModel: ImportViewModel = hiltViewModel(),
+) {
+ LaunchedEffect(uri) { viewModel.load(uri) }
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ BackHandler(onBack = onClose)
+
+ // A single event isn't shown here — it opens the create form for review.
+ LaunchedEffect(state) {
+ (state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
+ }
+
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.import_title)) },
+ navigationIcon = {
+ IconButton(onClick = onClose) {
+ Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ )
+ },
+ ) { padding ->
+ Box(Modifier.fillMaxSize().padding(padding)) {
+ when (val s = state) {
+ ImportUiState.Loading,
+ ImportUiState.Importing,
+ is ImportUiState.Single,
+ -> CircularProgressIndicator(Modifier.align(Alignment.Center))
+
+ 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.Done -> DoneContent(s, onClose)
+ }
+ }
+ }
+}
+
+@Composable
+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(horizontal = 16.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ pluralStringResource(R.plurals.import_event_count, state.events.size, state.events.size),
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier.padding(vertical = 8.dp),
+ )
+ 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) {
+ Column(
+ Modifier.fillMaxSize().padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ 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,
+ )
+ if (state.summary.skippedDuplicate > 0) {
+ Text(
+ pluralStringResource(
+ R.plurals.import_done_skipped,
+ state.summary.skippedDuplicate,
+ state.summary.skippedDuplicate,
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Button(onClick = onClose, modifier = Modifier.padding(top = 12.dp)) {
+ Text(stringResource(R.string.import_close))
+ }
+ }
+}
+
+@Composable
+private fun WarningText(warning: IcsParseWarning) {
+ val text = when (warning) {
+ IcsParseWarning.ModifiedOccurrenceSkipped -> stringResource(R.string.import_warning_recurrence)
+ IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start)
+ IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees)
+ IcsParseWarning.UnknownTimezone -> stringResource(R.string.import_warning_timezone)
+ }
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+}
+
+@Composable
+private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(
+ Modifier.padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(message, style = MaterialTheme.typography.bodyLarge)
+ if (onClose != null) {
+ Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt
new file mode 100644
index 0000000..63debea
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt
@@ -0,0 +1,105 @@
+package de.jeanlucmakiola.calendula.ui.imports
+
+import android.net.Uri
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
+import de.jeanlucmakiola.calendula.data.di.IoDispatcher
+import de.jeanlucmakiola.calendula.data.ics.IcsImporter
+import de.jeanlucmakiola.calendula.domain.CalendarSource
+import de.jeanlucmakiola.calendula.domain.EventForm
+import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
+import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
+import de.jeanlucmakiola.calendula.domain.ics.IcsParser
+import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
+import de.jeanlucmakiola.calendula.domain.ics.toEventForm
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.datetime.TimeZone
+import kotlin.coroutines.cancellation.CancellationException
+import javax.inject.Inject
+
+/** What an opened/received `.ics` resolved to. */
+sealed interface ImportUiState {
+ data object Loading : ImportUiState
+ data object Importing : ImportUiState
+
+ /** The file held no importable event (or couldn't be read/parsed as one). */
+ data object Empty : ImportUiState
+ data object Failed : ImportUiState
+
+ /** Exactly one event → review it in the prefilled create form. */
+ data class Single(val form: EventForm, val warnings: Set) : ImportUiState
+
+ /** Several events → pick a target calendar and bulk-import. */
+ data class Many(
+ val events: List,
+ val warnings: Set,
+ val calendars: List,
+ ) : ImportUiState
+
+ data class Done(val summary: IcsImportSummary) : ImportUiState
+}
+
+/**
+ * Reads an `.ics` [Uri], parses it (pure [IcsParser]) and routes by event count:
+ * one event opens the create form for review, many open the bulk-import picker.
+ * The bulk import dedups by UID in the repository.
+ */
+@HiltViewModel
+class ImportViewModel @Inject constructor(
+ private val repository: CalendarRepository,
+ private val importer: IcsImporter,
+ @IoDispatcher private val io: CoroutineDispatcher,
+) : ViewModel() {
+
+ private val parser = IcsParser()
+ private val _state = MutableStateFlow(ImportUiState.Loading)
+ val state: StateFlow = _state.asStateFlow()
+ private var started = false
+
+ /** Read + parse [uri] once; subsequent calls (recomposition) are ignored. */
+ fun load(uri: Uri) {
+ if (started) return
+ started = true
+ viewModelScope.launch {
+ val parsed = withContext(io) {
+ importer.readText(uri)?.let(parser::parse)
+ }
+ _state.value = when {
+ parsed == null -> ImportUiState.Failed
+ parsed.events.isEmpty() -> ImportUiState.Empty
+ parsed.events.size == 1 -> ImportUiState.Single(
+ form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
+ warnings = parsed.warnings,
+ )
+ else -> ImportUiState.Many(
+ events = parsed.events,
+ warnings = parsed.warnings,
+ calendars = repository.calendars().first().filter { it.canModifyContents },
+ )
+ }
+ }
+ }
+
+ /** Bulk-import the parsed events into [targetCalendarId]; result → [ImportUiState.Done]. */
+ fun import(targetCalendarId: Long) {
+ val many = _state.value as? ImportUiState.Many ?: return
+ viewModelScope.launch {
+ _state.value = ImportUiState.Importing
+ _state.value = try {
+ ImportUiState.Done(repository.importEvents(targetCalendarId, many.events))
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ ImportUiState.Failed
+ }
+ }
+ }
+}
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 94bbca7..c10888e 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -309,4 +309,32 @@
- %d Termin exportiert.
- %d Termine exportiert.
+
+ Termine importieren
+ Zu Kalender hinzufügen
+ In dieser Datei wurden keine Termine gefunden.
+ Datei konnte nicht gelesen werden.
+ Kein beschreibbarer Kalender zum Importieren. Lege zuerst einen lokalen Kalender an.
+ Import abgeschlossen
+ Schließen
+ Einige geänderte Einzeltermine wiederkehrender Termine wurden übersprungen.
+ Ein Termin ohne Startzeit wurde übersprungen.
+ Gästelisten wurden nicht importiert.
+ Eine unbekannte Zeitzone wurde durch die deines Geräts ersetzt.
+
+ - %d Termin in dieser Datei.
+ - %d Termine in dieser Datei.
+
+
+ - %d Termin importieren
+ - %d Termine importieren
+
+
+ - %d Termin importiert.
+ - %d Termine importiert.
+
+
+ - %d bereits in diesem Kalender übersprungen.
+ - %d bereits in diesem Kalender übersprungen.
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 48ff3b2..e4c7ab1 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -306,6 +306,34 @@
- Exported %d event.
- Exported %d events.
+
+ Import events
+ Add to calendar
+ No events found in this file.
+ Couldn\'t read this file.
+ No writable calendar to import into. Create a local calendar first.
+ Import complete
+ Close
+ Some changed occurrences of recurring events were skipped.
+ An event without a start time was skipped.
+ Guest lists weren\'t imported.
+ An unknown time zone fell back to your device\'s.
+
+ - %d event in this file.
+ - %d events in this file.
+
+
+ - Import %d event
+ - Import %d events
+
+
+ - Imported %d event.
+ - Imported %d events.
+
+
+ - Skipped %d already in this calendar.
+ - Skipped %d already in this calendar.
+
New event
Create a new event
diff --git a/docs/superpowers/plans/2026-06-18-06-ics-import.md b/docs/superpowers/plans/2026-06-18-06-ics-import.md
index 362413d..082740e 100644
--- a/docs/superpowers/plans/2026-06-18-06-ics-import.md
+++ b/docs/superpowers/plans/2026-06-18-06-ics-import.md
@@ -80,43 +80,43 @@ den Rest der Datei durch.
## Tasks
**Parser-Engine (`domain/ics/`, reine Kotlin, JVM-Tests):**
-- [ ] `IcsText`: `unescapeText` + `unfold(lines)` ergänzen (+ Test)
-- [ ] `IcsLineParser`: `NAME;PARAM=v;PARAM=v:VALUE` → (name, params-map, value);
+- [x] `IcsText`: `unescapeText` + `unfold(lines)` ergänzen (+ Test)
+- [x] `IcsLineParser`: `NAME;PARAM=v;PARAM=v:VALUE` → (name, params-map, value);
Param-Werte ggf. in Quotes; Test (Kantenfälle: Doppelpunkt im Wert,
gequotete Params)
-- [ ] `ParsedIcsEvent` (wie `IcsEvent`, aber `uid: String?`) + Datum/Zeit-Parser
+- [x] `ParsedIcsEvent` (wie `IcsEvent`, aber `uid: String?`) + Datum/Zeit-Parser
(`VALUE=DATE` / `…Z` / `TZID` → Instant + isAllDay + zoneId)
-- [ ] `IcsParser.parse(text)` → `IcsParseResult(events, warnings)`: VCALENDAR/
+- [x] `IcsParser.parse(text)` → `IcsParseResult(events, warnings)`: VCALENDAR/
VEVENT-Walk, skip-and-report für `RECURRENCE-ID`/unbekannte `VTIMEZONE`/
Attendees; ein defektes VEVENT killt nicht den Rest. **Round-trip-Test**
gegen `IcsWriter`-Ausgabe (all-day, getimt, wiederkehrend+TZID, Reminder)
+ Fremd-Quirks (gefaltete Zeilen, fehlende UID, `PT…`-Trigger)
**Datenschicht (`data/calendar/` + `data/ics/`):**
-- [ ] `ParsedIcsEvent.toEventForm(zone)` (Einzel-Öffnen → `EventForm`); Test
-- [ ] Datasource: `existingUids(calendarId)` (Query `Events.UID_2445`) +
+- [x] `ParsedIcsEvent.toEventForm(zone)` (Einzel-Öffnen → `EventForm`); Test
+- [x] Datasource: `existingUids(calendarId)` (Query `Events.UID_2445`) +
`insertImported(event, calendarId)` (Insert mit vorgegebener/erzeugter UID)
-- [ ] Repository `importEvents(targetCalendarId, events)` → `ImportSummary`
+- [x] Repository `importEvents(targetCalendarId, events)` → `ImportSummary`
(imported / skippedDuplicate / skippedUnsupported); UID-Dedup; Test mit
Fake-Datasource
-- [ ] `IcsImporter` (`data/ics/`): `Uri` → Text lesen (UTF-8, `contentResolver`)
+- [x] `IcsImporter` (`data/ics/`): `Uri` → Text lesen (UTF-8, `contentResolver`)
**Intent + Routing:**
-- [ ] Manifest: `ACTION_VIEW`/`ACTION_SEND`, MIME `text/calendar`, `.ics`-
+- [x] Manifest: `ACTION_VIEW`/`ACTION_SEND`, MIME `text/calendar`, `.ics`-
Pfadmuster (`file`/`content`); `MainActivity` parst eingehenden `Uri`
-- [ ] Routing in `CalendarHost`: 1 Event → Erstellen-Formular vorausgefüllt;
+- [x] Routing in `CalendarHost`: 1 Event → Erstellen-Formular vorausgefüllt;
>1 → Bulk-Import-Screen; 0 → Hinweis
**UI:**
-- [ ] Bulk-Import-Screen: Ziel-Kalender-Picker (OptionCard, nur beschreibbare),
+- [x] Bulk-Import-Screen: Ziel-Kalender-Picker (OptionCard, nur beschreibbare),
Anzahl/Vorschau, Import-Button → `ImportSummary` als Ergebnis
-- [ ] Einzel-Öffnen: `EventEditScreen` mit vorausgefülltem Formular (neuer
+- [x] Einzel-Öffnen: `EventEditScreen` mit vorausgefülltem Formular (neuer
Prefill-Pfad im `EventEditViewModel`, ohne `eventId`)
-- [ ] Strings DE+EN: Import-Titel, Ziel-Auswahl, Ergebnis-Plurals
+- [x] Strings DE+EN: Import-Titel, Ziel-Auswahl, Ergebnis-Plurals
(importiert / übersprungen-vorhanden / übersprungen-nicht-unterstützt),
leere-Datei-Hinweis
**Abschluss:**
-- [ ] `./gradlew lint test assembleDebug` grün
-- [ ] CHANGELOG (`[Unreleased]`), ROADMAP/STATE; v2.7 cut **erst** wenn beide
+- [x] `./gradlew lint test assembleDebug` grün
+- [ ] CHANGELOG done; ROADMAP/STATE pending; v2.7 cut **erst** wenn beide
Branches gemerged sind und On-Device-Review durch ist