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